mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 01:32:16 +01:00
Compare commits
3 Commits
feature/fi
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fa680bf6 | ||
|
|
b647a6af71 | ||
|
|
f223bd23c4 |
@@ -1,108 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Auth;
|
|
||||||
|
|
||||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
|
||||||
use App\Mail\AuthApiTokenExpiredMail;
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
|
|
||||||
class AuthSendReminderForExpiringApiTokensCommand extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'auth:send-mails-expiring-api-tokens '.
|
|
||||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->comment('Sending reminder emails about expiring API tokens...');
|
|
||||||
$sentMails = 0;
|
|
||||||
Token::query()
|
|
||||||
->where('expires_at', '<=', Carbon::now()->addDays(7))
|
|
||||||
->whereNull('reminder_sent_at')
|
|
||||||
->with([
|
|
||||||
'client',
|
|
||||||
'user',
|
|
||||||
])
|
|
||||||
->whereHas('user', function (Builder $query): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', false);
|
|
||||||
})
|
|
||||||
->isApiToken(true)
|
|
||||||
->orderBy('created_at', 'asc')
|
|
||||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
|
||||||
/** @var Collection<int, Token> $tokens */
|
|
||||||
foreach ($tokens as $token) {
|
|
||||||
$user = $token->user;
|
|
||||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
|
|
||||||
$sentMails++;
|
|
||||||
if (! $dryRun) {
|
|
||||||
Mail::to($user->email)
|
|
||||||
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
|
|
||||||
$token->reminder_sent_at = Carbon::now();
|
|
||||||
$token->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
|
|
||||||
|
|
||||||
$this->comment('Sent emails about expired API tokens');
|
|
||||||
$sentMails = 0;
|
|
||||||
Token::query()
|
|
||||||
->where('expires_at', '<=', Carbon::now())
|
|
||||||
->whereNull('expired_info_sent_at')
|
|
||||||
->with([
|
|
||||||
'client',
|
|
||||||
'user',
|
|
||||||
])
|
|
||||||
->whereHas('user', function (Builder $query): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', false);
|
|
||||||
})
|
|
||||||
->isApiToken(true)
|
|
||||||
->orderBy('created_at', 'asc')
|
|
||||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
|
||||||
/** @var Collection<int, Token> $tokens */
|
|
||||||
foreach ($tokens as $token) {
|
|
||||||
$user = $token->user;
|
|
||||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
|
|
||||||
$sentMails++;
|
|
||||||
if (! $dryRun) {
|
|
||||||
Mail::to($user->email)
|
|
||||||
->queue(new AuthApiTokenExpiredMail($token, $user));
|
|
||||||
$token->expired_info_sent_at = Carbon::now();
|
|
||||||
$token->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,6 @@ class Kernel extends ConsoleKernel
|
|||||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
$schedule->command('auth:send-mails-expiring-api-tokens')
|
|
||||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
|
||||||
->everyTenMinutes();
|
|
||||||
|
|
||||||
$schedule->command('self-host:check-for-update')
|
$schedule->command('self-host:check-for-update')
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||||
->twiceDaily();
|
->twiceDaily();
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TimeEntryRoundingType: string
|
|
||||||
{
|
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Up = 'up';
|
|
||||||
case Down = 'down';
|
|
||||||
case Nearest = 'nearest';
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ use Filament\Resources\Resource;
|
|||||||
use Filament\Tables\Actions\Action;
|
use Filament\Tables\Actions\Action;
|
||||||
use Filament\Tables\Actions\BulkAction;
|
use Filament\Tables\Actions\BulkAction;
|
||||||
use Filament\Tables\Actions\DeleteAction;
|
use Filament\Tables\Actions\DeleteAction;
|
||||||
use Filament\Tables\Actions\DeleteBulkAction;
|
|
||||||
use Filament\Tables\Actions\ViewAction;
|
use Filament\Tables\Actions\ViewAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
|
|||||||
->filters([])
|
->filters([])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkAction::make('retry')
|
BulkAction::make('retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Retry')
|
||||||
->label('Retry selected')
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (Collection $records): void {
|
->action(function (Collection $records): void {
|
||||||
/** @var FailedJob $record */
|
/** @var FailedJob $record */
|
||||||
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
DeleteBulkAction::make(),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
DeleteAction::make(),
|
DeleteAction::make('Delete'),
|
||||||
ViewAction::make(),
|
ViewAction::make('View'),
|
||||||
Action::make('retry')
|
Action::make('retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (FailedJob $record): void {
|
->action(function (FailedJob $record): void {
|
||||||
@@ -113,6 +109,7 @@ class FailedJobResource extends Resource
|
|||||||
return [
|
return [
|
||||||
'index' => ListFailedJobs::route('/'),
|
'index' => ListFailedJobs::route('/'),
|
||||||
'view' => ViewFailedJobs::route('/{record}'),
|
'view' => ViewFailedJobs::route('/{record}'),
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\FailedJobResource;
|
use App\Filament\Resources\FailedJobResource;
|
||||||
use App\Models\FailedJob;
|
use App\Models\FailedJob;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
@@ -19,8 +19,7 @@ class ListFailedJobs extends ListRecords
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('retry_all')
|
Action::make('retry_all')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Retry all failed Jobs')
|
||||||
->label('Retry all')
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
Artisan::call('queue:retry all');
|
Artisan::call('queue:retry all');
|
||||||
@@ -31,8 +30,7 @@ class ListFailedJobs extends ListRecords
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
Action::make('delete_all')
|
Action::make('delete_all')
|
||||||
->icon('heroicon-o-trash')
|
->label('Delete all failed Jobs')
|
||||||
->label('Delete all')
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TokenResource\Pages;
|
use App\Filament\Resources\TokenResource\Pages;
|
||||||
|
use App\Models\Passport\Client;
|
||||||
use App\Models\Passport\Token;
|
use App\Models\Passport\Token;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
@@ -105,11 +106,17 @@ class TokenResource extends Resource
|
|||||||
->queries(
|
->queries(
|
||||||
true: function (Builder $query) {
|
true: function (Builder $query) {
|
||||||
/** @var Builder<Token> $query */
|
/** @var Builder<Token> $query */
|
||||||
return $query->isApiToken();
|
return $query->whereHas('client', function (Builder $query) {
|
||||||
|
/** @var Builder<Client> $query */
|
||||||
|
return $query->whereJsonContains('grant_types', 'personal_access');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
false: function (Builder $query) {
|
false: function (Builder $query) {
|
||||||
/** @var Builder<Token> $query */
|
/** @var Builder<Token> $query */
|
||||||
return $query->isApiToken(false);
|
return $query->whereHas('client', function (Builder $query) {
|
||||||
|
/** @var Builder<Client> $query */
|
||||||
|
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
blank: function (Builder $query) {
|
blank: function (Builder $query) {
|
||||||
/** @var Builder<Token> $query */
|
/** @var Builder<Token> $query */
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ class ReportController extends Controller
|
|||||||
false,
|
false,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true,
|
true
|
||||||
$report->properties->roundingType,
|
|
||||||
$report->properties->roundingMinutes,
|
|
||||||
);
|
);
|
||||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesQuery->clone(),
|
$timeEntriesQuery->clone(),
|
||||||
@@ -86,9 +84,7 @@ class ReportController extends Controller
|
|||||||
true,
|
true,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true,
|
true
|
||||||
$report->properties->roundingType,
|
|
||||||
$report->properties->roundingMinutes,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||||
|
|||||||
@@ -107,8 +107,6 @@ class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$properties->timezone = $timezone;
|
$properties->timezone = $timezone;
|
||||||
$properties->roundingType = $request->getPropertyRoundingType();
|
|
||||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
|
||||||
$report->properties = $properties;
|
$report->properties = $properties;
|
||||||
if ($isPublic) {
|
if ($isPublic) {
|
||||||
$report->share_secret = $reportService->generateSecret();
|
$report->share_secret = $reportService->generateSecret();
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
|
|||||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
use App\Service\TimeEntryService;
|
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
use Gotenberg\Exceptions\GotenbergApiErrored;
|
||||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||||
@@ -48,7 +47,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
@@ -86,8 +84,7 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -141,19 +138,10 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
|
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||||
{
|
{
|
||||||
$select = TimeEntry::SELECT_COLUMNS;
|
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
|
||||||
if ($roundingType !== null && $roundingMinutes !== null) {
|
|
||||||
$select = array_diff($select, ['start', 'end']);
|
|
||||||
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
|
|
||||||
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
|
|
||||||
}
|
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->select($select)
|
|
||||||
->orderBy('start', 'desc');
|
->orderBy('start', 'desc');
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
@@ -187,19 +175,16 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -222,9 +207,8 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$user->timezone,
|
$user->timezone,
|
||||||
@@ -232,9 +216,7 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$showBillableRate,
|
$showBillableRate
|
||||||
$roundingType,
|
|
||||||
$roundingMinutes,
|
|
||||||
);
|
);
|
||||||
$html = Blade::render($viewFile, [
|
$html = Blade::render($viewFile, [
|
||||||
'timeEntries' => $timeEntriesQuery->get(),
|
'timeEntries' => $timeEntriesQuery->get(),
|
||||||
@@ -336,15 +318,12 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
@@ -355,9 +334,7 @@ class TimeEntryController extends Controller
|
|||||||
$request->getFillGapsInTimeGroups(),
|
$request->getFillGapsInTimeGroups(),
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate,
|
$showBillableRate
|
||||||
$roundingType,
|
|
||||||
$roundingMinutes
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -385,7 +362,6 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
@@ -397,8 +373,6 @@ class TimeEntryController extends Controller
|
|||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -409,9 +383,7 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate,
|
$showBillableRate
|
||||||
$roundingType,
|
|
||||||
$roundingMinutes
|
|
||||||
);
|
);
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -422,9 +394,7 @@ class TimeEntryController extends Controller
|
|||||||
true,
|
true,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate,
|
$showBillableRate
|
||||||
$roundingType,
|
|
||||||
$roundingMinutes
|
|
||||||
);
|
);
|
||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
@@ -507,7 +477,7 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
||||||
{
|
{
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization');
|
->whereBelongsTo($organization, 'organization');
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Http\Requests\V1\Report;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
@@ -129,18 +128,6 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'timezone:all',
|
'timezone:all',
|
||||||
],
|
],
|
||||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
|
||||||
'properties.rounding_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TimeEntryRoundingType::class),
|
|
||||||
],
|
|
||||||
// Defines the length of the interval that the time entry rounding rounds to.
|
|
||||||
'properties.rounding_minutes' => [
|
|
||||||
'nullable',
|
|
||||||
'numeric',
|
|
||||||
'integer',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,22 +205,4 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
|
||||||
{
|
|
||||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPropertyRoundingMinutes(): ?int
|
|
||||||
{
|
|
||||||
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->input('properties.rounding_minutes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Http\Requests\V1\TimeEntry;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -165,18 +164,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
|
||||||
'rounding_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TimeEntryRoundingType::class),
|
|
||||||
],
|
|
||||||
// Defines the length of the interval that the time entry rounding rounds to.
|
|
||||||
'rounding_minutes' => [
|
|
||||||
'nullable',
|
|
||||||
'numeric',
|
|
||||||
'integer',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,22 +211,4 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingMinutes(): ?int
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->validated('rounding_minutes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -147,18 +146,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
|
||||||
'rounding_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TimeEntryRoundingType::class),
|
|
||||||
],
|
|
||||||
// Defines the length of the interval that the time entry rounding rounds to.
|
|
||||||
'rounding_minutes' => [
|
|
||||||
'nullable',
|
|
||||||
'numeric',
|
|
||||||
'integer',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,22 +173,4 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingMinutes(): ?int
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->validated('rounding_minutes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -134,18 +133,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
|
||||||
'rounding_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TimeEntryRoundingType::class),
|
|
||||||
],
|
|
||||||
// Defines the length of the interval that the time entry rounding rounds to.
|
|
||||||
'rounding_minutes' => [
|
|
||||||
'nullable',
|
|
||||||
'numeric',
|
|
||||||
'integer',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,22 +170,4 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
{
|
{
|
||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingMinutes(): ?int
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->validated('rounding_minutes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -12,10 +11,8 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +23,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|RuleContract>>
|
* @return array<string, array<string|ValidationRule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -139,18 +136,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
|
||||||
'rounding_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TimeEntryRoundingType::class),
|
|
||||||
],
|
|
||||||
// Defines the length of the interval that the time entry rounding rounds to.
|
|
||||||
'rounding_minutes' => [
|
|
||||||
'nullable',
|
|
||||||
'numeric',
|
|
||||||
'integer',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,22 +153,4 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingMinutes(): ?int
|
|
||||||
{
|
|
||||||
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->validated('rounding_minutes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ class DetailedReportResource extends BaseResource
|
|||||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||||
/** @var string|null $rounding_type Rounding type for time entries */
|
|
||||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
|
||||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
|
||||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
|
||||||
],
|
],
|
||||||
/** @var string $created_at Date when the report was created */
|
/** @var string $created_at Date when the report was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
|
|
||||||
class AuthApiTokenExpirationReminderMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public Token $token;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new message instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct(Token $token, User $user)
|
|
||||||
{
|
|
||||||
$this->token = $token;
|
|
||||||
$this->user = $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the message.
|
|
||||||
*/
|
|
||||||
public function build(): self
|
|
||||||
{
|
|
||||||
return $this->markdown('emails.auth-api-expiration-reminder', [
|
|
||||||
'profileUrl' => URL::to('user/profile'),
|
|
||||||
'tokenName' => $this->token->name,
|
|
||||||
])
|
|
||||||
->subject(__('Your API token will expire in 7 days!'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
|
|
||||||
class AuthApiTokenExpiredMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public Token $token;
|
|
||||||
|
|
||||||
public User $user;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new message instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct(Token $token, User $user)
|
|
||||||
{
|
|
||||||
$this->token = $token;
|
|
||||||
$this->user = $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the message.
|
|
||||||
*/
|
|
||||||
public function build(): self
|
|
||||||
{
|
|
||||||
return $this->markdown('emails.auth-api-token-expired', [
|
|
||||||
'profileUrl' => URL::to('user/profile'),
|
|
||||||
'tokenName' => $this->token->name,
|
|
||||||
])
|
|
||||||
->subject(__('Your API token has expired!'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ namespace App\Models\Passport;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Factories\Passport\TokenFactory;
|
use Database\Factories\Passport\TokenFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -19,15 +18,11 @@ use Laravel\Passport\Token as PassportToken;
|
|||||||
* @property null|string $name
|
* @property null|string $name
|
||||||
* @property array<string> $scopes
|
* @property array<string> $scopes
|
||||||
* @property bool $revoked
|
* @property bool $revoked
|
||||||
* @property Carbon|null $reminder_sent_at
|
|
||||||
* @property Carbon|null $expired_info_sent_at
|
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $expires_at
|
* @property Carbon|null $expires_at
|
||||||
* @property-read Client|null $client
|
* @property-read Client|null $client
|
||||||
* @property-read User|null $user
|
* @property-read User|null $user
|
||||||
*
|
|
||||||
* @method Builder<Token> isApiToken(bool $isApiToken = true)
|
|
||||||
*/
|
*/
|
||||||
class Token extends PassportToken
|
class Token extends PassportToken
|
||||||
{
|
{
|
||||||
@@ -57,40 +52,4 @@ class Token extends PassportToken
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'scopes' => 'array',
|
|
||||||
'revoked' => 'bool',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'reminder_sent_at' => 'datetime',
|
|
||||||
'expired_info_sent_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<static> $query
|
|
||||||
* @return Builder<static>
|
|
||||||
*/
|
|
||||||
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
|
|
||||||
{
|
|
||||||
if ($isApiToken) {
|
|
||||||
return $query->whereHas('client', function (Builder $query): void {
|
|
||||||
/** @var Builder<Client> $query */
|
|
||||||
$query->whereJsonContains('grant_types', 'personal_access');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return $query->whereHas('client', function (Builder $query): void {
|
|
||||||
/** @var Builder<Client> $query */
|
|
||||||
$query->whereJsonDoesntContain('grant_types', 'personal_access');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,26 +77,6 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
'still_active_email_sent_at' => 'datetime',
|
'still_active_email_sent_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public const array SELECT_COLUMNS = [
|
|
||||||
'id',
|
|
||||||
'description',
|
|
||||||
'start',
|
|
||||||
'end',
|
|
||||||
'billable_rate',
|
|
||||||
'billable',
|
|
||||||
'user_id',
|
|
||||||
'organization_id',
|
|
||||||
'project_id',
|
|
||||||
'task_id',
|
|
||||||
'tags',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'member_id',
|
|
||||||
'client_id',
|
|
||||||
'is_imported',
|
|
||||||
'still_active_email_sent_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are computed. (f.e. for performance reasons)
|
* The attributes that are computed. (f.e. for performance reasons)
|
||||||
* These attributes can be regenerated at any time.
|
* These attributes can be regenerated at any time.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
|
||||||
use Brick\Money\Money;
|
use Brick\Money\Money;
|
||||||
|
|
||||||
class CurrencyService
|
class CurrencyService
|
||||||
@@ -375,12 +374,4 @@ class CurrencyService
|
|||||||
|
|
||||||
return $currencyCode;
|
return $currencyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRandomCurrencyCode(): string
|
|
||||||
{
|
|
||||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
|
||||||
$currencyCodes = array_keys($currencies);
|
|
||||||
|
|
||||||
return $currencyCodes[array_rand($currencyCodes)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Service\Dto;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
@@ -60,10 +59,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public ?Collection $taskIds = null;
|
public ?Collection $taskIds = null;
|
||||||
|
|
||||||
public ?TimeEntryRoundingType $roundingType = null;
|
|
||||||
|
|
||||||
public ?int $roundingMinutes = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the caster class to use when casting from / to this cast target.
|
* Get the caster class to use when casting from / to this cast target.
|
||||||
*
|
*
|
||||||
@@ -120,10 +115,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||||
$dto->weekStart = Weekday::from($data->weekStart);
|
$dto->weekStart = Weekday::from($data->weekStart);
|
||||||
$dto->timezone = $data->timezone;
|
$dto->timezone = $data->timezone;
|
||||||
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
|
|
||||||
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
|
||||||
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
|
||||||
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
|
||||||
|
|
||||||
return $dto;
|
return $dto;
|
||||||
}
|
}
|
||||||
@@ -149,8 +140,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
'historyGroup' => $value->historyGroup->value,
|
'historyGroup' => $value->historyGroup->value,
|
||||||
'weekStart' => $value->weekStart->value,
|
'weekStart' => $value->weekStart->value,
|
||||||
'timezone' => $value->timezone,
|
'timezone' => $value->timezone,
|
||||||
'roundingType' => $value->roundingType?->value,
|
|
||||||
'roundingMinutes' => $value->roundingMinutes,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$jsonString = json_encode($data);
|
$jsonString = json_encode($data);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Service;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -42,7 +41,7 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||||
{
|
{
|
||||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
@@ -57,14 +56,15 @@ class TimeEntryAggregationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
|
||||||
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
|
|
||||||
|
|
||||||
$timeEntriesQuery->selectRaw(
|
$timeEntriesQuery->selectRaw(
|
||||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
' round(
|
||||||
|
sum(
|
||||||
|
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
||||||
|
)
|
||||||
|
) as cost'
|
||||||
);
|
);
|
||||||
if ($groupBy !== null) {
|
if ($groupBy !== null) {
|
||||||
$timeEntriesQuery->groupBy($groupBy);
|
$timeEntriesQuery->groupBy($groupBy);
|
||||||
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||||
{
|
{
|
||||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||||
|
|
||||||
$keysGroup1 = [];
|
$keysGroup1 = [];
|
||||||
$keysGroup2 = [];
|
$keysGroup2 = [];
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
class TimeEntryService
|
|
||||||
{
|
|
||||||
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
|
||||||
{
|
|
||||||
if ($roundingType === null || $roundingMinutes === null) {
|
|
||||||
return 'start';
|
|
||||||
}
|
|
||||||
if ($roundingMinutes < 1) {
|
|
||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
|
||||||
{
|
|
||||||
if ($roundingType === null || $roundingMinutes === null) {
|
|
||||||
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
|
||||||
}
|
|
||||||
if ($roundingMinutes < 1) {
|
|
||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
|
||||||
}
|
|
||||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
|
||||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,8 +118,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": [
|
"dont-discover": [
|
||||||
"laravel/telescope",
|
"laravel/telescope"
|
||||||
"nwidart/laravel-modules"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -198,7 +197,6 @@ return [
|
|||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
App\Providers\JetstreamServiceProvider::class,
|
||||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||||
LaravelModulesServiceProvider::class,
|
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ return [
|
|||||||
|
|
||||||
'tasks' => [
|
'tasks' => [
|
||||||
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
||||||
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
|
|
||||||
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
||||||
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
||||||
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
|
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\CurrencyService;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +27,7 @@ class OrganizationFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->unique()->company(),
|
'name' => $this->faker->unique()->company(),
|
||||||
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
|
'currency' => $this->faker->currencyCode(),
|
||||||
'billable_rate' => null,
|
'billable_rate' => null,
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'personal_team' => true,
|
'personal_team' => true,
|
||||||
|
|||||||
@@ -36,22 +36,6 @@ class ClientFactory extends BaseClientFactory
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function desktopClient(): self
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'name' => 'Desktop',
|
|
||||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function apiClient(): self
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'name' => 'API',
|
|
||||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function personalAccessClient(): self
|
public function personalAccessClient(): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) {
|
return $this->state(function (array $attributes) {
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ class TokenFactory extends Factory
|
|||||||
'created_at' => $this->faker->dateTime,
|
'created_at' => $this->faker->dateTime,
|
||||||
'updated_at' => $this->faker->dateTime,
|
'updated_at' => $this->faker->dateTime,
|
||||||
'expires_at' => $this->faker->dateTime,
|
'expires_at' => $this->faker->dateTime,
|
||||||
'reminder_sent_at' => null,
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,16 +153,6 @@ class TimeEntryFactory extends Factory
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function endWithDuration(Carbon $end, int $durationInSeconds): self
|
|
||||||
{
|
|
||||||
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
|
|
||||||
return [
|
|
||||||
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
|
|
||||||
'end' => $end->copy()->utc(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function start(Carbon $start): self
|
public function start(Carbon $start): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) use ($start): array {
|
return $this->state(function (array $attributes) use ($start): array {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
|
||||||
$table->dateTime('reminder_sent_at')->nullable();
|
|
||||||
$table->dateTime('expired_info_sent_at')->nullable();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('reminder_sent_at');
|
|
||||||
$table->dropColumn('expired_info_sent_at');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -31,7 +31,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry for ${projectName}`);
|
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
await page.getByText(projectName).click();
|
await page.getByText(projectName).click();
|
||||||
@@ -52,7 +52,7 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
|
|||||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry with tag ${tagName}`);
|
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||||
|
|
||||||
// Add tag
|
// Add tag
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
await page.getByRole('button', { name: 'Tags' }).click();
|
||||||
@@ -74,7 +74,7 @@ async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean
|
|||||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||||
|
|
||||||
// Set billable status
|
// Set billable status
|
||||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||||
@@ -103,7 +103,7 @@ test('test that project filtering works in reporting', async ({ page }) => {
|
|||||||
// Go to reporting and filter by project1
|
// Go to reporting and filter by project1
|
||||||
await goToReporting(page);
|
await goToReporting(page);
|
||||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||||
await page.getByRole('dialog').getByText(project1).click();
|
await page.getByText(project1).click();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// escape
|
// escape
|
||||||
@@ -114,8 +114,8 @@ test('test that project filtering works in reporting', async ({ page }) => {
|
|||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Verify only project1 time entries are shown
|
// Verify only project1 time entries are shown
|
||||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
await expect(page.getByText(project1)).toBeVisible();
|
||||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
await expect(page.getByText(project2)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||||
@@ -142,7 +142,7 @@ test('test that tag filtering works in reporting', async ({ page }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify only time entries with tag1 are shown
|
// Verify only time entries with tag1 are shown
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||||
@@ -164,7 +164,7 @@ test('test that billable status filtering works in reporting', async ({ page })
|
|||||||
]);
|
]);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -16,7 +16,7 @@
|
|||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
public/fonts/Outfit-Bold.ttf
Normal file
BIN
public/fonts/Outfit-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Medium.ttf
Normal file
BIN
public/fonts/Outfit-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Regular.ttf
Normal file
BIN
public/fonts/Outfit-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
Binary file not shown.
@@ -160,15 +160,30 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Inter Variable Font with browser compatibility considerations */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Outfit';
|
||||||
src: url('/fonts/Inter-Variable.woff2') format('woff2'),
|
src: url('/fonts/Outfit-Regular.ttf');
|
||||||
url('/fonts/Inter-Variable.ttf') format('truetype');
|
font-weight: 400;
|
||||||
font-weight: 100 900;
|
}
|
||||||
font-style: normal;
|
@font-face {
|
||||||
font-display: swap;
|
font-family: 'Outfit';
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
src: url('/fonts/Outfit-Medium.ttf');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
src: url('/fonts/Outfit-SemiBold.ttf');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
src: url('/fonts/Outfit-Bold.ttf');
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
src: url('/fonts/Outfit-ExtraBold.ttf');
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -190,7 +205,7 @@ body {
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
--input: var(--color-border-tertiary);
|
--input: var(--theme-color-input-background);
|
||||||
--ring: var(--theme-color-ring);
|
--ring: var(--theme-color-ring);
|
||||||
--chart-1: var(--color-accent-400);
|
--chart-1: var(--color-accent-400);
|
||||||
--chart-2: var(--color-accent-500);
|
--chart-2: var(--color-accent-500);
|
||||||
@@ -217,7 +232,7 @@ body {
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
--input: var(--color-border-tertiary);
|
--input: var(--theme-color-input-background);
|
||||||
--ring: var(--theme-color-ring);
|
--ring: var(--theme-color-ring);
|
||||||
--chart-1: var(--color-accent-200);
|
--chart-1: var(--color-accent-200);
|
||||||
--chart-2: var(--color-accent-300);
|
--chart-2: var(--color-accent-300);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const option = computed(() => ({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
margin: 16,
|
margin: 16,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -139,7 +139,7 @@ const option = computed(() => ({
|
|||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/Components/ui/button';
|
import Badge from '@/packages/ui/src/Badge.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
icon: Component;
|
icon: Component;
|
||||||
@@ -12,39 +12,32 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
|
|
||||||
const activeClass = computed(() => {
|
const activeClass = computed(() => {
|
||||||
if (props.active) {
|
if (props.active) {
|
||||||
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
|
return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconClass = computed(() => {
|
|
||||||
return twMerge(
|
|
||||||
'-ml-0.5 h-4 w-4',
|
|
||||||
props.active ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-text-quaternary'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button
|
<Badge
|
||||||
variant="outline"
|
size="large"
|
||||||
size="sm"
|
tag="button"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
|
'cursor-pointer bg-input-background hover:bg-card-background transition flex',
|
||||||
activeClass
|
activeClass
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
:class="iconClass"
|
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
|
||||||
></component>
|
|
||||||
<span class="text-nowrap"> {{ title }} </span>
|
<span class="text-nowrap"> {{ title }} </span>
|
||||||
<div
|
<div
|
||||||
v-if="count"
|
v-if="count"
|
||||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||||
{{ count }}
|
{{ count }}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
|
||||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
@@ -34,7 +33,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
|
|||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
|
|
||||||
import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue';
|
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
|
||||||
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import {
|
import {
|
||||||
@@ -55,9 +54,6 @@ import type { ExportFormat } from '@/types/reporting';
|
|||||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
|
||||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
@@ -75,9 +71,6 @@ const selectedTasks = ref<string[]>([]);
|
|||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
|
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
const billable = ref<'true' | 'false' | null>(null);
|
||||||
const roundingEnabled = ref<boolean>(false);
|
|
||||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
|
||||||
const roundingMinutes = ref<number>(15);
|
|
||||||
|
|
||||||
const group = useStorage<GroupingOption>('reporting-group', 'project');
|
const group = useStorage<GroupingOption>('reporting-group', 'project');
|
||||||
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
||||||
@@ -91,11 +84,6 @@ const { groupByOptions } = reportingStore;
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
// Watch rounding enabled state to trigger updates
|
|
||||||
watch(roundingEnabled, () => {
|
|
||||||
updateReporting();
|
|
||||||
});
|
|
||||||
|
|
||||||
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||||
let params: AggregatedTimeEntriesQueryParams = {
|
let params: AggregatedTimeEntriesQueryParams = {
|
||||||
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
||||||
@@ -123,8 +111,6 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
|||||||
getCurrentRole() === 'employee'
|
getCurrentRole() === 'employee'
|
||||||
? getCurrentMembershipId()
|
? getCurrentMembershipId()
|
||||||
: undefined,
|
: undefined,
|
||||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
|
||||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
|
||||||
};
|
};
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -319,7 +305,7 @@ const tableData = computed(() => {
|
|||||||
<div class="py-2.5 w-full border-b border-default-background-separator">
|
<div class="py-2.5 w-full border-b border-default-background-separator">
|
||||||
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
|
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
|
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
|
||||||
<div class="text-sm font-medium">Filters</div>
|
<div class="text-sm font-medium">Filters</div>
|
||||||
<MemberMultiselectDropdown
|
<MemberMultiselectDropdown
|
||||||
v-model="selectedMembers"
|
v-model="selectedMembers"
|
||||||
@@ -409,11 +395,6 @@ const tableData = computed(() => {
|
|||||||
:icon="BillableIcon"></ReportingFilterBadge>
|
:icon="BillableIcon"></ReportingFilterBadge>
|
||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
<ReportingRoundingControls
|
|
||||||
v-model:enabled="roundingEnabled"
|
|
||||||
v-model:type="roundingType"
|
|
||||||
v-model:minutes="roundingMinutes"
|
|
||||||
@change="updateReporting"></ReportingRoundingControls>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
@@ -509,7 +490,7 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
||||||
<p class="text-lg text-text-primary font-medium">
|
<p class="text-lg text-text-primary font-semibold">
|
||||||
No time entries found
|
No time entries found
|
||||||
</p>
|
</p>
|
||||||
<p>Try to change the filters and time range</p>
|
<p>Try to change the filters and time range</p>
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Switch } from '@/Components/ui/switch';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
|
||||||
import { Button } from '@/Components/ui/button';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@/Components/ui/select';
|
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
|
||||||
import {
|
|
||||||
NumberField,
|
|
||||||
NumberFieldInput,
|
|
||||||
NumberFieldContent,
|
|
||||||
NumberFieldIncrement,
|
|
||||||
NumberFieldDecrement
|
|
||||||
} from '@/Components/ui/number-field';
|
|
||||||
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { CreditCardIcon } from '@heroicons/vue/20/solid';
|
|
||||||
// TimeEntryRoundingType definition
|
|
||||||
const TimeEntryRoundingType = {
|
|
||||||
Up: 'up' as const,
|
|
||||||
Down: 'down' as const,
|
|
||||||
Nearest: 'nearest' as const,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
enabled: boolean;
|
|
||||||
type: TimeEntryRoundingType;
|
|
||||||
minutes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:enabled': [value: boolean];
|
|
||||||
'update:type': [value: TimeEntryRoundingType];
|
|
||||||
'update:minutes': [value: number];
|
|
||||||
'change': [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function updateEnabled(value: boolean) {
|
|
||||||
emit('update:enabled', value);
|
|
||||||
emit('change');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateType(value: TimeEntryRoundingType) {
|
|
||||||
emit('update:type', value);
|
|
||||||
emit('change');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMinutes(value: number) {
|
|
||||||
emit('update:minutes', value);
|
|
||||||
emit('change');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predefined intervals
|
|
||||||
const predefinedIntervals = [
|
|
||||||
{ value: '5', label: '5 minutes' },
|
|
||||||
{ value: '6', label: '6 minutes' },
|
|
||||||
{ value: '10', label: '10 minutes' },
|
|
||||||
{ value: '15', label: '15 minutes' },
|
|
||||||
{ value: '30', label: '30 minutes' },
|
|
||||||
{ value: '60', label: '1 hour' },
|
|
||||||
{ value: 'custom', label: 'Custom' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const showCustomInput = ref(false);
|
|
||||||
const customMinutes = ref(props.minutes);
|
|
||||||
const selectedInterval = ref('');
|
|
||||||
|
|
||||||
// Compute the current interval value based on props
|
|
||||||
const currentInterval = computed(() => {
|
|
||||||
const predefined = predefinedIntervals.find(interval =>
|
|
||||||
interval.value !== 'custom' && parseInt(interval.value) === props.minutes
|
|
||||||
);
|
|
||||||
return predefined ? predefined.value : 'custom';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize selectedInterval
|
|
||||||
const initializeSelectedInterval = () => {
|
|
||||||
selectedInterval.value = currentInterval.value;
|
|
||||||
showCustomInput.value = selectedInterval.value === 'custom';
|
|
||||||
if (showCustomInput.value) {
|
|
||||||
customMinutes.value = props.minutes;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleIntervalChange(value: string) {
|
|
||||||
selectedInterval.value = value;
|
|
||||||
if (value === 'custom') {
|
|
||||||
showCustomInput.value = true;
|
|
||||||
// Update minutes to current custom value to ensure "custom" shows as selected
|
|
||||||
updateMinutes(customMinutes.value);
|
|
||||||
} else {
|
|
||||||
showCustomInput.value = false;
|
|
||||||
const minutes = parseInt(value);
|
|
||||||
updateMinutes(minutes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCustomMinutesChange(value: string | number) {
|
|
||||||
const numValue = typeof value === 'string' ? parseInt(value) : value;
|
|
||||||
if (!isNaN(numValue) && numValue > 0) {
|
|
||||||
customMinutes.value = numValue;
|
|
||||||
updateMinutes(numValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for changes in props.minutes
|
|
||||||
watch(() => props.minutes, (newMinutes) => {
|
|
||||||
customMinutes.value = newMinutes;
|
|
||||||
initializeSelectedInterval();
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
watch(currentInterval, () => {
|
|
||||||
initializeSelectedInterval();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Active styling similar to ReportingFilterBadge
|
|
||||||
const activeClass = computed(() => {
|
|
||||||
if (props.enabled) {
|
|
||||||
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconClass = computed(() => {
|
|
||||||
return twMerge(
|
|
||||||
'w-4 h-4',
|
|
||||||
props.enabled ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-muted-foreground opacity-50'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:class="twMerge(activeClass)">
|
|
||||||
<ArrowsUpDownIcon :class="iconClass" />
|
|
||||||
Rounding {{ enabled ? 'on' : 'off' }}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent class="w-72 p-4">
|
|
||||||
<div v-if="!isAllowedToPerformPremiumAction()" class="flex flex-col space-y-2">
|
|
||||||
<span class="font-semibold text-xs">Premium</span>
|
|
||||||
<span class="text-xs text-text-secondary flex-1">Rounding is a premium feature. Upgrade to unlock this feature.</span>
|
|
||||||
<Link href="/billing">
|
|
||||||
<Button size="sm" variant="input" class="items-center space-x-1">
|
|
||||||
<CreditCardIcon class="w-3.5 h-3.5 text-text-tertiary mr-1" />
|
|
||||||
Go to Billing
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
|
||||||
<Switch
|
|
||||||
id="enable-rounding"
|
|
||||||
:model-value="enabled"
|
|
||||||
class="data-[state=checked]:bg-accent-500"
|
|
||||||
@update:model-value="updateEnabled" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary">
|
|
||||||
Rounding is applied to each individual time entry, not to the accumulated total.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
|
|
||||||
<Select
|
|
||||||
:model-value="type"
|
|
||||||
:disabled="!enabled"
|
|
||||||
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
|
|
||||||
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled">
|
|
||||||
<SelectValue placeholder="Select rounding type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="up">Round Up</SelectItem>
|
|
||||||
<SelectItem value="down">Round Down</SelectItem>
|
|
||||||
<SelectItem value="nearest">Round Nearest</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
|
|
||||||
<Select
|
|
||||||
:model-value="selectedInterval"
|
|
||||||
:disabled="!enabled"
|
|
||||||
@update:model-value="(value) => handleIntervalChange(value as string)">
|
|
||||||
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled">
|
|
||||||
<SelectValue placeholder="Select interval" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="interval in predefinedIntervals"
|
|
||||||
:key="interval.value"
|
|
||||||
:value="interval.value">
|
|
||||||
{{ interval.label }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div v-if="showCustomInput" class="mt-2">
|
|
||||||
<NumberField
|
|
||||||
id="custom-minutes"
|
|
||||||
:model-value="customMinutes"
|
|
||||||
size="small"
|
|
||||||
:min="1"
|
|
||||||
:max="1440"
|
|
||||||
:disabled="!enabled"
|
|
||||||
class="text-sm"
|
|
||||||
@update:model-value="handleCustomMinutesChange">
|
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldDecrement :disabled="!enabled" />
|
|
||||||
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" />
|
|
||||||
<NumberFieldIncrement :disabled="!enabled" />
|
|
||||||
</NumberFieldContent>
|
|
||||||
</NumberField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
|
||||||
@@ -43,7 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-text-secondary font-medium text-xs">
|
<div class="text-text-secondary font-extrabold text-xs">
|
||||||
Current Timer
|
Current Timer
|
||||||
</div>
|
</div>
|
||||||
<div class="text-text-primary font-medium text-lg">
|
<div class="text-text-primary font-medium text-lg">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const option = computed(() => ({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
margin: 24,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator">
|
class="px-3.5 py-2 flex justify-between @container border-b border-card-background-separator">
|
||||||
<div class="flex items-center min-w-[70px]">
|
<div class="flex items-center min-w-[70px]">
|
||||||
<p class="font-medium text-sm text-text-primary">
|
<p class="font-semibold text-sm text-text-primary">
|
||||||
{{ formatHumanReadableDate(date) }}
|
{{ formatHumanReadableDate(date) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +28,7 @@ defineProps<{
|
|||||||
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-medium">
|
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async function startTaskTimer() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator">
|
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="font-medium text-text-primary text-sm pb-1 truncate">
|
<p class="font-medium text-text-primary text-sm pb-1 truncate">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
<div class="px-4 py-2 2xl:py-3 border-b border-card-background-separator">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p class="font-semibold text-sm text-text-primary">
|
<p class="font-semibold text-sm text-text-primary">
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const option = computed(() => {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
margin: 24,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
@@ -215,7 +215,7 @@ const option = computed(() => {
|
|||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
<CollapsibleRoot v-else v-model:open="open"
|
<CollapsibleRoot v-else v-model:open="open"
|
||||||
><CollapsibleTrigger class="w-full group py-0.5">
|
><CollapsibleTrigger class="w-full group py-0.5">
|
||||||
<div
|
<div
|
||||||
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
|
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
@@ -41,7 +41,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
current
|
current
|
||||||
? 'text-icon-active'
|
? 'text-icon-active'
|
||||||
: 'text-icon-default group-hover:text-icon-active',
|
: 'text-icon-default group-hover:text-icon-active',
|
||||||
'transition h-4 w-4 shrink-0',
|
'transition h-5 w-5 shrink-0',
|
||||||
]"
|
]"
|
||||||
aria-hidden="true" />
|
aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ defineProps<{
|
|||||||
current
|
current
|
||||||
? 'bg-menu-active text-text-primary'
|
? 'bg-menu-active text-text-primary'
|
||||||
: 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ',
|
: 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ',
|
||||||
'group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center',
|
'group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center',
|
||||||
]">
|
]">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
@@ -25,7 +25,7 @@ defineProps<{
|
|||||||
current
|
current
|
||||||
? 'text-icon-active'
|
? 'text-icon-active'
|
||||||
: 'text-icon-default group-hover:text-icon-active',
|
: 'text-icon-default group-hover:text-icon-active',
|
||||||
'transition h-4 w-4 shrink-0',
|
'transition h-5 w-5 shrink-0',
|
||||||
]"
|
]"
|
||||||
aria-hidden="true" />
|
aria-hidden="true" />
|
||||||
{{ title }}
|
{{ title }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|||||||
export { default as Button } from './Button.vue'
|
export { default as Button } from './Button.vue'
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
outline:
|
outline:
|
||||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const delegatedProps = computed(() => {
|
|||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
const sizeClasses = computed(() => {
|
const sizeClasses = computed(() => {
|
||||||
return props.size === 'small' ? 'h-[34px] text-sm' : 'h-[42px]'
|
return props.size === 'small' ? 'h-[34px]' : 'h-[42px]'
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<SwitchRoot
|
<SwitchRoot
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-white bg-white/50',
|
||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<SwitchThumb
|
<SwitchThumb
|
||||||
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4')"
|
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0')"
|
||||||
>
|
>
|
||||||
<slot name="thumb" />
|
<slot name="thumb" />
|
||||||
</SwitchThumb>
|
</SwitchThumb>
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const page = usePage<{
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-text-tertiary text-xs font-semibold pt-5 pb-1.5">
|
class="text-text-tertiary text-sm font-semibold pt-5 pb-1.5">
|
||||||
Manage
|
Manage
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ const page = usePage<{
|
|||||||
</nav>
|
</nav>
|
||||||
<div
|
<div
|
||||||
v-if="canUpdateOrganization()"
|
v-if="canUpdateOrganization()"
|
||||||
class="text-text-tertiary text-xs font-semibold pt-5 pb-1.5">
|
class="text-text-tertiary text-sm font-semibold pt-5 pb-1.5">
|
||||||
Admin
|
Admin
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
@@ -70,9 +69,6 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|||||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
|
||||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
@@ -87,17 +83,9 @@ const selectedMembers = ref<string[]>([]);
|
|||||||
const selectedTasks = ref<string[]>([]);
|
const selectedTasks = ref<string[]>([]);
|
||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
const billable = ref<'true' | 'false' | null>(null);
|
||||||
const roundingEnabled = ref<boolean>(false);
|
|
||||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
|
||||||
const roundingMinutes = ref<number>(15);
|
|
||||||
|
|
||||||
const { members } = storeToRefs(useMembersStore());
|
const { members } = storeToRefs(useMembersStore());
|
||||||
const pageLimit = 15;
|
const pageLimit = 15;
|
||||||
|
|
||||||
// Watch rounding enabled state to trigger updates
|
|
||||||
watch(roundingEnabled, () => {
|
|
||||||
updateFilteredTimeEntries();
|
|
||||||
});
|
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
|
|
||||||
function getFilterAttributes() {
|
function getFilterAttributes() {
|
||||||
@@ -127,8 +115,6 @@ function getFilterAttributes() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||||
billable: billable.value !== null ? billable.value : undefined,
|
billable: billable.value !== null ? billable.value : undefined,
|
||||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
|
||||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
|
||||||
};
|
};
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -282,7 +268,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
<MainContainer
|
<MainContainer
|
||||||
class="sm:flex space-y-4 sm:space-y-0 justify-between">
|
class="sm:flex space-y-4 sm:space-y-0 justify-between">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
|
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
|
||||||
<div class="text-sm font-medium">Filters</div>
|
<div class="text-sm font-medium">Filters</div>
|
||||||
<MemberMultiselectDropdown
|
<MemberMultiselectDropdown
|
||||||
v-model="selectedMembers"
|
v-model="selectedMembers"
|
||||||
@@ -372,11 +358,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:icon="BillableIcon"></ReportingFilterBadge>
|
:icon="BillableIcon"></ReportingFilterBadge>
|
||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
<ReportingRoundingControls
|
|
||||||
v-model:enabled="roundingEnabled"
|
|
||||||
v-model:type="roundingType"
|
|
||||||
v-model:minutes="roundingMinutes"
|
|
||||||
@change="updateFilteredTimeEntries" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
|
|||||||
@@ -123,12 +123,7 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
|||||||
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||||
SolidTimeApi,
|
SolidTimeApi,
|
||||||
'getAggregatedTimeEntries'
|
'getAggregatedTimeEntries'
|
||||||
> & {
|
> & { start: string; end: string };
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
rounding_type?: string;
|
|
||||||
rounding_minutes?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrganizationResponse = ZodiosResponseByAlias<
|
export type OrganizationResponse = ZodiosResponseByAlias<
|
||||||
SolidTimeApi,
|
SolidTimeApi,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const tagClasses = computed(() => {
|
|||||||
tagClasses,
|
tagClasses,
|
||||||
badgeClasses[size],
|
badgeClasses[size],
|
||||||
borderClasses,
|
borderClasses,
|
||||||
'rounded transition inline-flex items-center font-medium text-text-primary disabled:text-text-quaternary outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
'rounded transition inline-flex items-center font-semibold text-text-primary disabled:text-text-quaternary outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-3">
|
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-4">
|
||||||
<h3
|
<h3
|
||||||
class="text-text-primary font-medium text-sm lg:text-base flex items-center space-x-1.5 lg:space-x-2">
|
class="text-text-primary font-semibold text-sm lg:text-base flex items-center space-x-2 lg:space-x-2.5">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
v-if="icon"
|
v-if="icon"
|
||||||
class="w-4 lg:w-4 text-icon-default"></component>
|
class="w-5 lg:w-6 text-icon-default"></component>
|
||||||
<span>
|
<span>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/Components/ui/popover';
|
} from '@/Components/ui/popover';
|
||||||
import { Button } from '@/Components/ui/button';
|
|
||||||
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
||||||
import { CalendarDate } from '@internationalized/date';
|
import { CalendarDate } from '@internationalized/date';
|
||||||
import { CalendarIcon } from 'lucide-vue-next';
|
import { CalendarIcon } from 'lucide-vue-next';
|
||||||
@@ -209,15 +208,14 @@ watch(open, (value) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover v-model:open="open">
|
<Popover v-model:open="open">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 h-[34px] shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||||
!modelValue && 'text-muted-foreground'
|
!modelValue && 'text-muted-foreground'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
<template v-if="modelValue.start">
|
<template v-if="modelValue.start">
|
||||||
<template v-if="modelValue.end">
|
<template v-if="modelValue.end">
|
||||||
{{
|
{{
|
||||||
@@ -244,23 +242,23 @@ watch(open, (value) => {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else> Pick a date </template>
|
<template v-else> Pick a date </template>
|
||||||
</Button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-auto p-0">
|
<PopoverContent class="w-auto p-0">
|
||||||
<div class="flex divide-x divide-border-secondary">
|
<div class="flex divide-x divide-border-secondary">
|
||||||
<div
|
<div
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1">
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setToday">Today</Button>
|
<button @click="setToday">Today</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek">This Week</Button>
|
<button @click="setThisWeek">This Week</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek">Last Week</Button>
|
<button @click="setLastWeek">Last Week</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days">Last 14 days</Button>
|
<button @click="setLast14Days">Last 14 days</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth">This Month</Button>
|
<button @click="setThisMonth">This Month</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth">Last Month</Button>
|
<button @click="setLastMonth">Last Month</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days">Last 30 days</Button>
|
<button @click="setLast30Days">Last 30 days</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days">Last 90 days</Button>
|
<button @click="setLast90Days">Last 90 days</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months">Last 12 months</Button>
|
<button @click="setLast12Months">Last 12 months</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear">This year</Button>
|
<button @click="setThisYear">This year</button>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear">Last year</Button>
|
<button @click="setLastYear">Last year</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2">
|
<div class="pl-2">
|
||||||
<RangeCalendar
|
<RangeCalendar
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
"></BillableToggleButton>
|
"></BillableToggleButton>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<button
|
<button
|
||||||
:class="twMerge('text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]')"
|
:class="twMerge('text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
|
||||||
@click="expanded = !expanded">
|
@click="expanded = !expanded">
|
||||||
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ type BillableOption = {
|
|||||||
id="description"
|
id="description"
|
||||||
ref="description"
|
ref="description"
|
||||||
v-model="timeEntry.description"
|
v-model="timeEntry.description"
|
||||||
aria-label="Description"
|
|
||||||
placeholder="What did you work on?"
|
placeholder="What did you work on?"
|
||||||
type="text"
|
type="text"
|
||||||
class="mt-1 block w-full"
|
class="mt-1 block w-full"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
showDate
|
showDate
|
||||||
? 'text-xs py-1.5 font-semibold'
|
? 'text-xs py-1.5 font-semibold'
|
||||||
: 'text-sm py-1.5 font-medium',
|
: 'text-sm py-1.5 font-medium',
|
||||||
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
|
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]',
|
||||||
open && 'border-card-border bg-card-background'
|
open && 'border-card-border bg-card-background'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function selectUnselectAll(value: boolean) {
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="w-5">
|
<div class="w-5">
|
||||||
<svg
|
<svg
|
||||||
class="w-3 sm:w-4 text-icon-default group-hover:hidden block"
|
class="w-4 sm:w-5 text-icon-default group-hover:hidden block"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<g fill="none">
|
<g fill="none">
|
||||||
@@ -54,15 +54,15 @@ function selectUnselectAll(value: boolean) {
|
|||||||
class="group-hover:block hidden"
|
class="group-hover:block hidden"
|
||||||
@update:checked="selectUnselectAll"></Checkbox>
|
@update:checked="selectUnselectAll"></Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-text-primary">
|
<span class="font-semibold text-text-primary">
|
||||||
{{ formatWeekday(date) }}
|
{{ formatWeekday(date) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-medium text-text-secondary">
|
<span class="font-semibold text-text-secondary">
|
||||||
{{ formatDate(date, organization?.date_format) }}
|
{{ formatDate(date, organization?.date_format) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
|
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
|
||||||
<span class="font-medium">
|
<span class="font-semibold">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
|||||||
v-model="tempDescription"
|
v-model="tempDescription"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
data-testid="time_entry_description"
|
data-testid="time_entry_description"
|
||||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
|
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
@keydown.enter="startTimerIfNotActive"
|
@keydown.enter="startTimerIfNotActive"
|
||||||
@keydown.esc="showDropdown = false"
|
@keydown.esc="showDropdown = false"
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
@component('mail::message')
|
|
||||||
|
|
||||||
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
|
|
||||||
|
|
||||||
|
|
||||||
{{ __('You can create a new API token in your profile:') }}
|
|
||||||
|
|
||||||
@component('mail::button', ['url' => $profileUrl])
|
|
||||||
{{ __('Go to your profile') }}
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@endcomponent
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@component('mail::message')
|
|
||||||
|
|
||||||
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
|
|
||||||
|
|
||||||
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
|
|
||||||
|
|
||||||
{{ __('You can create a new API token in your profile:') }}
|
|
||||||
|
|
||||||
@component('mail::button', ['url' => $profileUrl])
|
|
||||||
{{ __('Go to your profile') }}
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@endcomponent
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<x-filament-widgets::widget>
|
<x-filament-widgets::widget>
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-950 font-bold dark:text-white">Version</span>
|
<span class="text-gray-950 font-bold">Version</span>
|
||||||
@if($version !== null)
|
@if($version !== null)
|
||||||
<span>v{{ $version }}</span>
|
<span>v{{ $version }}</span>
|
||||||
@else
|
@else
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
@endif
|
@endif
|
||||||
<br>
|
<br>
|
||||||
<span class="text-gray-950 font-bold dark:text-white">Build</span>
|
<span class="text-gray-950 font-bold">Build</span>
|
||||||
@if($build !== null)
|
@if($build !== null)
|
||||||
<span>{{ $build }}</span>
|
<span>{{ $build }}</span>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import typography from "@tailwindcss/typography";
|
|||||||
|
|
||||||
/** @type {import("tailwindcss").Config} */
|
/** @type {import("tailwindcss").Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["selector", ".dark"],
|
darkMode: ["selector", "class"],
|
||||||
content: [
|
content: [
|
||||||
"./extensions/Invoicing/resources/js/**/*.vue",
|
"./extensions/Invoicing/resources/js/**/*.vue",
|
||||||
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
|
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
|
||||||
"./vendor/laravel/jetstream/**/*.blade.php",
|
"./vendor/laravel/jetstream/**/*.blade.php",
|
||||||
"./storage/framework/views/*.php",
|
"./storage/framework/views/*.php",
|
||||||
"./resources/views/**/*.blade.php",
|
"./resources/views/**/*.blade.php",
|
||||||
"./resources/js/**/*.vue",
|
"./resources/js/**/*.vue"
|
||||||
"./resources/js/**/*.ts"
|
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@@ -25,25 +24,10 @@ export default {
|
|||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: [
|
sans: [
|
||||||
"Inter",
|
"Outfit",
|
||||||
...defaultTheme.fontFamily.sans
|
...defaultTheme.fontFamily.sans
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
fontSize: {
|
|
||||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
|
||||||
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
|
||||||
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
|
||||||
lg: ['1rem', { lineHeight: '1.5rem' }],
|
|
||||||
xl: ['1.125rem', { lineHeight: '1.75rem' }],
|
|
||||||
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
|
||||||
'3xl': ['1.5rem', { lineHeight: '2rem' }],
|
|
||||||
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
|
|
||||||
'5xl': ['2rem', { lineHeight: '1' }],
|
|
||||||
'6xl': ['2.25rem', { lineHeight: '1' }],
|
|
||||||
'7xl': ['2.5rem', { lineHeight: '1' }],
|
|
||||||
'8xl': ['3rem', { lineHeight: '1' }],
|
|
||||||
'9xl': ['3.5rem', { lineHeight: '1' }]
|
|
||||||
},
|
|
||||||
colors: {
|
colors: {
|
||||||
ring: "var(--ring)",
|
ring: "var(--ring)",
|
||||||
primary: {
|
primary: {
|
||||||
|
|||||||
@@ -21,17 +21,13 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
use CreatesApplication;
|
use CreatesApplication;
|
||||||
|
|
||||||
protected bool $mockBillingContract = true;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
LogFake::bind();
|
LogFake::bind();
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
if ($this->mockBillingContract) {
|
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||||
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
|
||||||
}
|
|
||||||
// Note: The following line can be used to test timezone edge cases.
|
// Note: The following line can be used to test timezone edge cases.
|
||||||
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
|
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Console\Commands\Auth;
|
|
||||||
|
|
||||||
use App\Console\Commands\Auth\AuthSendReminderForExpiringApiTokensCommand;
|
|
||||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
|
||||||
use App\Mail\AuthApiTokenExpiredMail;
|
|
||||||
use App\Models\Passport\Client;
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Tests\TestCaseWithDatabase;
|
|
||||||
|
|
||||||
#[CoversClass(AuthSendReminderForExpiringApiTokensCommand::class)]
|
|
||||||
class AuthSendReminderForExpiringApiTokensCommandTest extends TestCaseWithDatabase
|
|
||||||
{
|
|
||||||
public function test_sends_mail_for_expired_api_tokens_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
$apiClient = Client::factory()->apiClient()->create();
|
|
||||||
$otherClient = Client::factory()->desktopClient()->create();
|
|
||||||
$expiredToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
|
|
||||||
'reminder_sent_at' => Carbon::now()->subDays(8),
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
'expires_at' => Carbon::now()->subDay(),
|
|
||||||
]);
|
|
||||||
$expiredTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
|
|
||||||
'reminder_sent_at' => Carbon::now()->subDays(8),
|
|
||||||
'expired_info_sent_at' => Carbon::now(),
|
|
||||||
'expires_at' => Carbon::now()->subDay(),
|
|
||||||
]);
|
|
||||||
$nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([
|
|
||||||
'reminder_sent_at' => null,
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
'expires_at' => Carbon::now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
|
||||||
$expiredToken->refresh();
|
|
||||||
$expiredTokenWithMailSent->refresh();
|
|
||||||
$nonApiToken->refresh();
|
|
||||||
$this->assertNotNull($expiredToken->expired_info_sent_at);
|
|
||||||
$this->assertNotNull($expiredTokenWithMailSent->expired_info_sent_at);
|
|
||||||
$this->assertNull($nonApiToken->reminder_sent_at);
|
|
||||||
$this->assertNull($nonApiToken->expired_info_sent_at);
|
|
||||||
Mail::assertNotQueued(AuthApiTokenExpirationReminderMail::class);
|
|
||||||
Mail::assertQueued(AuthApiTokenExpiredMail::class, function (AuthApiTokenExpiredMail $mail) use ($user, $expiredToken): bool {
|
|
||||||
return $mail->hasTo($user->user->email) &&
|
|
||||||
$mail->token->is($expiredToken) &&
|
|
||||||
$mail->user->is($user->user);
|
|
||||||
});
|
|
||||||
|
|
||||||
$output = Artisan::output();
|
|
||||||
$this->assertStringContainsString('Finished sending 0 expiring API token emails...', $output);
|
|
||||||
$this->assertStringContainsString('Finished sending 1 expired API token emails...', $output);
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
'Start sending email to user "'.$user->user->email.'" ('.
|
|
||||||
$user->user->id.') about expired API token '.$expiredToken->getKey(), $output);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_sends_mail_for_api_tokens_that_expire_soon_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
$apiClient = Client::factory()->apiClient()->create();
|
|
||||||
$otherClient = Client::factory()->desktopClient()->create();
|
|
||||||
$expiringToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
|
|
||||||
'reminder_sent_at' => null,
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
'expires_at' => Carbon::now()->addDays(6),
|
|
||||||
]);
|
|
||||||
$expiringTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
|
|
||||||
'reminder_sent_at' => Carbon::now(),
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
'expires_at' => Carbon::now()->addDays(6),
|
|
||||||
]);
|
|
||||||
$nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([
|
|
||||||
'reminder_sent_at' => null,
|
|
||||||
'expired_info_sent_at' => null,
|
|
||||||
'expires_at' => Carbon::now()->addDays(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
|
||||||
$expiringToken->refresh();
|
|
||||||
$expiringTokenWithMailSent->refresh();
|
|
||||||
$nonApiToken->refresh();
|
|
||||||
$this->assertNotNull($expiringToken->reminder_sent_at);
|
|
||||||
$this->assertNull($expiringToken->expired_info_sent_at);
|
|
||||||
$this->assertNotNull($expiringTokenWithMailSent->reminder_sent_at);
|
|
||||||
$this->assertNull($expiringTokenWithMailSent->expired_info_sent_at);
|
|
||||||
$this->assertNull($nonApiToken->reminder_sent_at);
|
|
||||||
$this->assertNull($nonApiToken->expired_info_sent_at);
|
|
||||||
Mail::assertNotQueued(AuthApiTokenExpiredMail::class);
|
|
||||||
Mail::assertQueued(AuthApiTokenExpirationReminderMail::class, function (AuthApiTokenExpirationReminderMail $mail) use ($user, $expiringToken): bool {
|
|
||||||
return $mail->hasTo($user->user->email) &&
|
|
||||||
$mail->token->is($expiringToken) &&
|
|
||||||
$mail->user->is($user->user);
|
|
||||||
});
|
|
||||||
|
|
||||||
$output = Artisan::output();
|
|
||||||
$this->assertStringContainsString('Finished sending 1 expiring API token emails...', $output);
|
|
||||||
$this->assertStringContainsString('Finished sending 0 expired API token emails...', $output);
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
'Start sending email to user "'.$user->user->email.'" ('.
|
|
||||||
$user->user->id.') reminding about API token '.$expiringToken->getKey(), $output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Controllers\Api\V1\ReportController;
|
use App\Http\Controllers\Api\V1\ReportController;
|
||||||
use App\Models\Report;
|
use App\Models\Report;
|
||||||
@@ -163,61 +162,6 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_creates_new_report_with_rounding_properties(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'reports:create',
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
|
|
||||||
'name' => 'Test Report with Rounding',
|
|
||||||
'description' => 'Test description',
|
|
||||||
'is_public' => true,
|
|
||||||
'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),
|
|
||||||
'properties' => [
|
|
||||||
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
|
|
||||||
'end' => Carbon::now()->toIso8601ZuluString(),
|
|
||||||
'active' => true,
|
|
||||||
'member_ids' => [],
|
|
||||||
'billable' => true,
|
|
||||||
'client_ids' => [],
|
|
||||||
'project_ids' => [],
|
|
||||||
'tag_ids' => [],
|
|
||||||
'task_ids' => [],
|
|
||||||
'group' => TimeEntryAggregationType::Project->value,
|
|
||||||
'sub_group' => TimeEntryAggregationType::Task->value,
|
|
||||||
'history_group' => TimeEntryAggregationType::Day->value,
|
|
||||||
'week_start' => Weekday::Monday->value,
|
|
||||||
'timezone' => 'Europe/Berlin',
|
|
||||||
'rounding_type' => 'nearest',
|
|
||||||
'rounding_minutes' => 15,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(201);
|
|
||||||
/** @var Report $report */
|
|
||||||
$report = Report::query()->findOrFail($response->json('data.id'));
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->where('data.name', 'Test Report with Rounding')
|
|
||||||
->where('data.description', 'Test description')
|
|
||||||
->where('data.is_public', true)
|
|
||||||
->where('data.shareable_link', $report->getShareableLink())
|
|
||||||
->where('data.properties.group', TimeEntryAggregationType::Project->value)
|
|
||||||
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
|
|
||||||
->where('data.properties.rounding_type', 'nearest')
|
|
||||||
->where('data.properties.rounding_minutes', 15)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also verify the properties are saved in the database
|
|
||||||
$this->assertSame(TimeEntryRoundingType::Nearest, $report->properties->roundingType);
|
|
||||||
$this->assertSame(15, $report->properties->roundingMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
|
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Enums\ExportFormat;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
use App\Jobs\RecalculateSpentTimeForProject;
|
||||||
@@ -390,190 +389,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_can_round_up(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
|
|
||||||
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
|
|
||||||
]);
|
|
||||||
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
$this->actAsOrganizationWithSubscription();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'rounding_type' => TimeEntryRoundingType::Up,
|
|
||||||
'rounding_minutes' => 6,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->has('meta')
|
|
||||||
->where('meta.total', 2)
|
|
||||||
->count('data', 2)
|
|
||||||
->where('data.0.id', $timeEntry1->getKey())
|
|
||||||
->where('data.0.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.0.end', '2020-01-01T00:06:00Z')
|
|
||||||
->where('data.1.id', $timeEntry2->getKey())
|
|
||||||
->where('data.1.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.1.end', '2020-01-01T00:18:00Z')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
|
|
||||||
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
|
|
||||||
]);
|
|
||||||
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'rounding_type' => TimeEntryRoundingType::Up,
|
|
||||||
'rounding_minutes' => 6,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->has('meta')
|
|
||||||
->where('meta.total', 2)
|
|
||||||
->count('data', 2)
|
|
||||||
->where('data.0.id', $timeEntry1->getKey())
|
|
||||||
->where('data.0.start', '2020-01-01T00:00:08Z')
|
|
||||||
->where('data.0.end', '2020-01-01T00:00:01Z')
|
|
||||||
->where('data.1.id', $timeEntry2->getKey())
|
|
||||||
->where('data.1.start', '2020-01-01T00:00:07Z')
|
|
||||||
->where('data.1.end', null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_can_round_down(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
|
|
||||||
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
|
|
||||||
]);
|
|
||||||
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
$this->actAsOrganizationWithSubscription();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'rounding_type' => TimeEntryRoundingType::Down,
|
|
||||||
'rounding_minutes' => 6,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->has('meta')
|
|
||||||
->where('meta.total', 2)
|
|
||||||
->count('data', 2)
|
|
||||||
->where('data.0.id', $timeEntry1->getKey())
|
|
||||||
->where('data.0.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.0.end', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.1.id', $timeEntry2->getKey())
|
|
||||||
->where('data.1.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.1.end', '2020-01-01T00:12:00Z')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_can_round_nearest(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:00'));
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
|
|
||||||
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:02:59'),
|
|
||||||
]);
|
|
||||||
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
|
|
||||||
->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
$this->actAsOrganizationWithSubscription();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'rounding_type' => TimeEntryRoundingType::Nearest,
|
|
||||||
'rounding_minutes' => 6,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->has('meta')
|
|
||||||
->where('meta.total', 2)
|
|
||||||
->count('data', 2)
|
|
||||||
->where('data.0.id', $timeEntry1->getKey())
|
|
||||||
->where('data.0.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.0.end', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.1.id', $timeEntry2->getKey())
|
|
||||||
->where('data.1.start', '2020-01-01T00:00:00Z')
|
|
||||||
->where('data.1.end', '2020-01-01T00:18:00Z')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void
|
public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Mail;
|
|
||||||
|
|
||||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
|
||||||
use App\Models\Passport\Client;
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Tests\TestCaseWithDatabase;
|
|
||||||
|
|
||||||
#[CoversClass(AuthApiTokenExpirationReminderMail::class)]
|
|
||||||
class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
|
|
||||||
{
|
|
||||||
public function test_mail_renders_content_correctly(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$client = Client::factory()->apiClient()->create();
|
|
||||||
$token = Token::factory()->forClient($client)->forUser($user)->create([
|
|
||||||
'name' => 'TEST',
|
|
||||||
]);
|
|
||||||
$mail = new AuthApiTokenExpirationReminderMail($token, $user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$rendered = $mail->render();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Mail;
|
|
||||||
|
|
||||||
use App\Mail\AuthApiTokenExpiredMail;
|
|
||||||
use App\Models\Passport\Client;
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Tests\TestCaseWithDatabase;
|
|
||||||
|
|
||||||
#[CoversClass(AuthApiTokenExpiredMail::class)]
|
|
||||||
class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
|
|
||||||
{
|
|
||||||
public function test_mail_renders_content_correctly(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$client = Client::factory()->apiClient()->create();
|
|
||||||
$token = Token::factory()->forClient($client)->forUser($user)->create([
|
|
||||||
'name' => 'TEST',
|
|
||||||
]);
|
|
||||||
$mail = new AuthApiTokenExpiredMail($token, $user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$rendered = $mail->render();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Model\Passport;
|
|
||||||
|
|
||||||
use App\Models\Passport\Client;
|
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\User;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Tests\Unit\Model\ModelTestAbstract;
|
|
||||||
|
|
||||||
#[CoversClass(Token::class)]
|
|
||||||
class TokenModelTest extends ModelTestAbstract
|
|
||||||
{
|
|
||||||
public function test_it_belongs_to_a_client(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$client = Client::factory()->create();
|
|
||||||
$token = Token::factory()->forClient($client)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$token->refresh();
|
|
||||||
$clientRel = $token->client;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertNotNull($clientRel);
|
|
||||||
$this->assertTrue($clientRel->is($client));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_belongs_to_a_user(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$client = Client::factory()->create();
|
|
||||||
$token = Token::factory()->forUser($user)->forClient($client)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$token->refresh();
|
|
||||||
$userRel = $token->user;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertNotNull($userRel);
|
|
||||||
$this->assertTrue($userRel->is($user));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scope_is_api_tokens_only_returns_api_tokens_with_no_parameters(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$clientApi = Client::factory()->apiClient()->create();
|
|
||||||
$clientDesktop = Client::factory()->desktopClient()->create();
|
|
||||||
$token1 = Token::factory()->forClient($clientApi)->create();
|
|
||||||
$token2 = Token::factory()->forClient($clientDesktop)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$apiTokens = Token::query()
|
|
||||||
->isApiToken()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(1, $apiTokens);
|
|
||||||
$this->assertTrue($apiTokens->first()->is($token1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scope_is_api_tokens_only_returns_api_tokens_with_true(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$clientApi = Client::factory()->apiClient()->create();
|
|
||||||
$clientDesktop = Client::factory()->desktopClient()->create();
|
|
||||||
$token1 = Token::factory()->forClient($clientApi)->create();
|
|
||||||
$token2 = Token::factory()->forClient($clientDesktop)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$apiTokens = Token::query()
|
|
||||||
->isApiToken(true)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(1, $apiTokens);
|
|
||||||
$this->assertTrue($apiTokens->first()->is($token1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scope_is_api_tokens_only_returns_api_tokens_with_false(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$clientApi = Client::factory()->apiClient()->create();
|
|
||||||
$clientDesktop = Client::factory()->desktopClient()->create();
|
|
||||||
$token1 = Token::factory()->forClient($clientApi)->create();
|
|
||||||
$token2 = Token::factory()->forClient($clientDesktop)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$apiTokens = Token::query()
|
|
||||||
->isApiToken(false)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(1, $apiTokens);
|
|
||||||
$this->assertTrue($apiTokens->first()->is($token2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,15 +92,4 @@ class CurrencyServiceTest extends TestCaseWithDatabase
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertSame('XXX', $symbol);
|
$this->assertSame('XXX', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_get_random_currency_code(): void
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
$currencyCode = $this->currencyService->getRandomCurrencyCode();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertNotEmpty($currencyCode);
|
|
||||||
$this->assertIsString($currencyCode);
|
|
||||||
$this->assertNotNull(Currency::of($currencyCode));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service;
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -41,9 +40,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -90,9 +87,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -177,9 +172,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
false,
|
false
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -245,9 +238,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -289,9 +280,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -318,9 +307,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -356,9 +343,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -423,302 +408,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
], $result);
|
], $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_aggregate_time_can_round_up_per_time_entry(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$client1 = Client::factory()->create();
|
|
||||||
$client2 = Client::factory()->create();
|
|
||||||
$project1 = Project::factory()->forClient($client1)->create();
|
|
||||||
$project2 = Project::factory()->forClient($client2)->create();
|
|
||||||
$project3 = Project::factory()->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)
|
|
||||||
->forProject($project2)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
|
|
||||||
->forProject($project3)
|
|
||||||
->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
|
|
||||||
->create();
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Client,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
TimeEntryRoundingType::Up,
|
|
||||||
15
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertEqualsCanonicalizing([
|
|
||||||
'seconds' => 4500,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'client',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $project3->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client1->getKey(),
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project1->getKey(),
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client2->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project2->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_time_can_round_down_per_time_entry(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$client1 = Client::factory()->create();
|
|
||||||
$client2 = Client::factory()->create();
|
|
||||||
$project1 = Project::factory()->forClient($client1)->create();
|
|
||||||
$project2 = Project::factory()->forClient($client2)->create();
|
|
||||||
$project3 = Project::factory()->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)
|
|
||||||
->forProject($project2)->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 450)
|
|
||||||
->forProject($project3)
|
|
||||||
->create();
|
|
||||||
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 449)
|
|
||||||
->create();
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Client,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
TimeEntryRoundingType::Down,
|
|
||||||
15
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertEqualsCanonicalizing([
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'client',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $project3->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client1->getKey(),
|
|
||||||
'seconds' => 0,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project1->getKey(),
|
|
||||||
'seconds' => 0,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client2->getKey(),
|
|
||||||
'seconds' => 0,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project2->getKey(),
|
|
||||||
'seconds' => 0,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_time_can_round_to_nearest_per_time_entry(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$client1 = Client::factory()->create();
|
|
||||||
$client2 = Client::factory()->create();
|
|
||||||
$project1 = Project::factory()->forClient($client1)->create();
|
|
||||||
$project2 = Project::factory()->forClient($client2)->create();
|
|
||||||
$project3 = Project::factory()->create();
|
|
||||||
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 449)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
|
|
||||||
->forProject($project1)->create();
|
|
||||||
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
|
|
||||||
->forProject($project2)->create();
|
|
||||||
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
|
|
||||||
->forProject($project3)
|
|
||||||
->create();
|
|
||||||
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
|
|
||||||
->create();
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Client,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
TimeEntryRoundingType::Nearest,
|
|
||||||
15
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertEqualsCanonicalizing([
|
|
||||||
'seconds' => 3600,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'client',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 1800,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $project3->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client1->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project1->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $client2->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project2->getKey(),
|
|
||||||
'seconds' => 900,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: test with 1 minute
|
|
||||||
|
|
||||||
public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void
|
public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -743,9 +432,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -841,9 +528,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -927,9 +612,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true,
|
true
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
Reference in New Issue
Block a user