mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 17:52:15 +01:00
Compare commits
6 Commits
v0.10.0
...
feature/si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4623697f79 | ||
|
|
1c787a0ad0 | ||
|
|
5e678edb9d | ||
|
|
65e145b20f | ||
|
|
d2b636842a | ||
|
|
1510884f3b |
@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
|
|||||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
$schedule->command('self-host:check-for-update')
|
||||||
// Convert string to a stable integer for seeding
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
->twiceDaily();
|
||||||
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
|
||||||
$seed = abs($seed); // Ensure it's positive
|
|
||||||
mt_srand($seed);
|
|
||||||
$firstHour = mt_rand(0, 23);
|
|
||||||
$secondHour = ($firstHour + 12) % 24;
|
|
||||||
$minuteOffset = mt_rand(0, 59);
|
|
||||||
mt_srand(null); // Reset the random number generator
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
$schedule->command('self-host:telemetry')
|
||||||
$schedule->command('self-host:check-for-update')
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
->twiceDaily();
|
||||||
}
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
|
||||||
$schedule->command('self-host:telemetry')
|
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule->command('self-host:database-consistency')
|
$schedule->command('self-host:database-consistency')
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
|
|||||||
case Client = 'client';
|
case Client = 'client';
|
||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
case Tag = 'tag';
|
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class OverlappingTimeEntryApiException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'overlapping_time_entry';
|
|
||||||
}
|
|
||||||
@@ -38,17 +38,11 @@ class ClientController extends Controller
|
|||||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'clients:view');
|
$this->checkPermission($organization, 'clients:view');
|
||||||
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
if (! $canViewAllClients) {
|
|
||||||
$clientsQuery->visibleByEmployee($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filterArchived = $request->getFilterArchived();
|
$filterArchived = $request->getFilterArchived();
|
||||||
if ($filterArchived === 'true') {
|
if ($filterArchived === 'true') {
|
||||||
$clientsQuery->whereNotNull('archived_at');
|
$clientsQuery->whereNotNull('archived_at');
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getTimeFormat() !== null) {
|
if ($request->getTimeFormat() !== null) {
|
||||||
$organization->time_format = $request->getTimeFormat();
|
$organization->time_format = $request->getTimeFormat();
|
||||||
}
|
}
|
||||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
|
||||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
|
||||||
}
|
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
if ($hasBillableRate) {
|
if ($hasBillableRate) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
@@ -46,7 +45,6 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Http\File;
|
use Illuminate\Http\File;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
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;
|
||||||
@@ -58,43 +56,6 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|||||||
|
|
||||||
class TimeEntryController extends Controller
|
class TimeEntryController extends Controller
|
||||||
{
|
{
|
||||||
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
|
||||||
{
|
|
||||||
if (! $organization->prevent_overlapping_time_entries) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = TimeEntry::query()
|
|
||||||
->where('organization_id', $organization->getKey())
|
|
||||||
->where('user_id', $member->user_id)
|
|
||||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
|
||||||
$q->where('id', '!=', $exclude->getKey());
|
|
||||||
})
|
|
||||||
->where(function (Builder $q) use ($start, $end): void {
|
|
||||||
$q->where(function (Builder $q2) use ($start): void {
|
|
||||||
$q2->where('end', '>', $start)
|
|
||||||
->where('start', '<', $start);
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($end !== null) {
|
|
||||||
$q->orWhere(function (Builder $q4) use ($end): void {
|
|
||||||
$q4->where('start', '<', $end)
|
|
||||||
->where('end', '>', $end);
|
|
||||||
});
|
|
||||||
// Check if the new entry completely surrounds an existing entry
|
|
||||||
$q->orWhere(function (Builder $q6) use ($start, $end): void {
|
|
||||||
$q6->where('start', '>=', $start)
|
|
||||||
->where('end', '<=', $end);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($query->exists()) {
|
|
||||||
throw new OverlappingTimeEntryApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
||||||
{
|
{
|
||||||
parent::checkPermission($organization, $permission);
|
parent::checkPermission($organization, $permission);
|
||||||
@@ -588,15 +549,17 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryStillRunningApiException;
|
throw new TimeEntryStillRunningApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overlap check for create
|
|
||||||
$start = Carbon::parse($request->input('start'));
|
|
||||||
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
|
|
||||||
$this->assertNoOverlap($organization, $member, $start, $end);
|
|
||||||
|
|
||||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||||
$client = $project?->client;
|
$client = $project?->client;
|
||||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||||
|
|
||||||
|
if ($project !== null) {
|
||||||
|
RecalculateSpentTimeForProject::dispatch($project);
|
||||||
|
}
|
||||||
|
if ($task !== null) {
|
||||||
|
RecalculateSpentTimeForTask::dispatch($task);
|
||||||
|
}
|
||||||
|
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
@@ -606,13 +569,6 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeEntryResource($timeEntry);
|
return new TimeEntryResource($timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,13 +593,6 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryCanNotBeRestartedApiException;
|
throw new TimeEntryCanNotBeRestartedApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overlap check for update (exclude current)
|
|
||||||
/** @var Member $effectiveMember */
|
|
||||||
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
|
|
||||||
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
|
|
||||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
|
||||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'employees_can_see_billable_rates' => [
|
'employees_can_see_billable_rates' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
'prevent_overlapping_time_entries' => [
|
|
||||||
'boolean',
|
|
||||||
],
|
|
||||||
'number_format' => [
|
'number_format' => [
|
||||||
Rule::enum(NumberFormat::class),
|
Rule::enum(NumberFormat::class),
|
||||||
],
|
],
|
||||||
@@ -101,9 +98,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPreventOverlappingTimeEntries(): ?bool
|
|
||||||
{
|
|
||||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'changes.description' => [
|
'changes.description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'changes.tags' => [
|
'changes.tags' => [
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ class OrganizationResource extends BaseResource
|
|||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
|
||||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
|
||||||
/** @var string $currency Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
'currency' => $this->resource->currency,
|
'currency' => $this->resource->currency,
|
||||||
/** @var string $currency_symbol Currency symbol */
|
/** @var string $currency_symbol Currency symbol */
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\ClientFactory;
|
use Database\Factories\ClientFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -63,18 +62,6 @@ class Client extends Model implements AuditableContract
|
|||||||
return $this->hasMany(Project::class, 'client_id');
|
return $this->hasMany(Project::class, 'client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<Client> $builder
|
|
||||||
* @return Builder<Client>
|
|
||||||
*/
|
|
||||||
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
|
|
||||||
{
|
|
||||||
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
|
|
||||||
/** @var Builder<Project> $builder */
|
|
||||||
return $builder->visibleByEmployee($user);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Attribute<bool, never>
|
* @return Attribute<bool, never>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
'employees_can_see_billable_rates' => 'boolean',
|
||||||
'prevent_overlapping_time_entries' => 'boolean',
|
|
||||||
'number_format' => NumberFormat::class,
|
'number_format' => NumberFormat::class,
|
||||||
'currency_format' => CurrencyFormat::class,
|
'currency_format' => CurrencyFormat::class,
|
||||||
'date_format' => DateFormat::class,
|
'date_format' => DateFormat::class,
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -173,7 +172,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -234,7 +232,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -259,13 +256,12 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
'clients:view',
|
|
||||||
'time-entries:view:own',
|
'time-entries:view:own',
|
||||||
'time-entries:create:own',
|
'time-entries:create:own',
|
||||||
'time-entries:update:own',
|
'time-entries:update:own',
|
||||||
'time-entries:delete:own',
|
'time-entries:delete:own',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
|
||||||
|
|
||||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Description']) > 5000) {
|
if (strlen($record['Description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Notes']) > 5000) {
|
if (strlen($record['Notes']) > 500) {
|
||||||
throw new ImportException('Time entry note is too long');
|
throw new ImportException('Time entry note is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Notes'];
|
$timeEntry->description = $record['Notes'];
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($timeEntryRow['description']) > 5000) {
|
if (strlen($timeEntryRow['description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $timeEntryRow['description'];
|
$timeEntry->description = $timeEntryRow['description'];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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;
|
||||||
use App\Models\Tag;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -18,7 +17,6 @@ use Carbon\CarbonTimeZone;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TimeEntryAggregationService
|
class TimeEntryAggregationService
|
||||||
@@ -47,21 +45,9 @@ class TimeEntryAggregationService
|
|||||||
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, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||||
{
|
{
|
||||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
|
||||||
$baseTotalsQuery = $timeEntriesQuery->clone();
|
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
$group2Select = null;
|
$group2Select = null;
|
||||||
$groupBy = null;
|
$groupBy = null;
|
||||||
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
|
|
||||||
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
|
||||||
$timeEntriesQuery->crossJoin(DB::raw(
|
|
||||||
"LATERAL (\n".
|
|
||||||
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
|
|
||||||
" UNION ALL\n".
|
|
||||||
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
|
|
||||||
') AS tag(tag)'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if ($group1Type !== null) {
|
if ($group1Type !== null) {
|
||||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||||
$groupBy = ['group_1'];
|
$groupBy = ['group_1'];
|
||||||
@@ -98,26 +84,6 @@ class TimeEntryAggregationService
|
|||||||
$group1Response = [];
|
$group1Response = [];
|
||||||
$group1ResponseSum = 0;
|
$group1ResponseSum = 0;
|
||||||
$group1ResponseCost = 0;
|
$group1ResponseCost = 0;
|
||||||
// If Tag is subgroup, prepare base totals per primary group without tag expansion
|
|
||||||
$baseTotalsPerGroup1Map = [];
|
|
||||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
|
||||||
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
|
|
||||||
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
|
|
||||||
->selectRaw(
|
|
||||||
$group1Select.' as group_1,'.
|
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
|
||||||
)
|
|
||||||
->groupBy('group_1')
|
|
||||||
->get();
|
|
||||||
foreach ($baseTotalsPerGroup1 as $row) {
|
|
||||||
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
|
|
||||||
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
|
|
||||||
'aggregate' => (int) ($row->aggregate ?? 0),
|
|
||||||
'cost' => (int) ($row->cost ?? 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||||
/** @var string|int $group1 */
|
/** @var string|int $group1 */
|
||||||
$group2Response = [];
|
$group2Response = [];
|
||||||
@@ -137,14 +103,6 @@ class TimeEntryAggregationService
|
|||||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
||||||
}
|
}
|
||||||
// Override primary group totals when Tag is subgroup to avoid double counting
|
|
||||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
|
||||||
$keyForMap = (string) $group1;
|
|
||||||
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
|
|
||||||
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
|
|
||||||
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||||
@@ -163,23 +121,6 @@ class TimeEntryAggregationService
|
|||||||
$group1ResponseCost += $group2ResponseCost;
|
$group1ResponseCost += $group2ResponseCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
|
|
||||||
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
|
|
||||||
if ($hasTagGrouping) {
|
|
||||||
// Reset selects and ordering on the cloned base query
|
|
||||||
$baseTotals = $baseTotalsQuery
|
|
||||||
->selectRaw(
|
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
|
||||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
|
||||||
)
|
|
||||||
->first();
|
|
||||||
if ($baseTotals !== null) {
|
|
||||||
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
|
|
||||||
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
|
|
||||||
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($fillGapsInTimeGroupsIsPossible) {
|
if ($fillGapsInTimeGroupsIsPossible) {
|
||||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||||
}
|
}
|
||||||
@@ -353,17 +294,6 @@ class TimeEntryAggregationService
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} elseif ($type === TimeEntryAggregationType::Tag) {
|
|
||||||
$tags = Tag::query()
|
|
||||||
->whereIn('id', $keys)
|
|
||||||
->select('id', 'name')
|
|
||||||
->get();
|
|
||||||
foreach ($tags as $tag) {
|
|
||||||
$descriptorMap[$tag->id] = [
|
|
||||||
'description' => $tag->name,
|
|
||||||
'color' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $descriptorMap;
|
return $descriptorMap;
|
||||||
@@ -506,8 +436,6 @@ class TimeEntryAggregationService
|
|||||||
return 'billable';
|
return 'billable';
|
||||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||||
return 'description';
|
return 'description';
|
||||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
|
||||||
return 'tag';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +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('organizations', function (Blueprint $table): void {
|
|
||||||
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('organizations', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('prevent_overlapping_time_entries');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +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('time_entries', function (Blueprint $table): void {
|
|
||||||
$table->string('description', 5000)->change();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('time_entries', function (Blueprint $table): void {
|
|
||||||
$table->string('description', 500)->change();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
|||||||
|
|
||||||
CREATE TABLE public.time_entries (
|
CREATE TABLE public.time_entries (
|
||||||
id uuid NOT NULL,
|
id uuid NOT NULL,
|
||||||
description character varying(5000) NOT NULL,
|
description character varying(500) NOT NULL,
|
||||||
start timestamp(0) without time zone NOT NULL,
|
start timestamp(0) without time zone NOT NULL,
|
||||||
"end" timestamp(0) without time zone,
|
"end" timestamp(0) without time zone,
|
||||||
billable_rate integer,
|
billable_rate integer,
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
|
|||||||
|
|
||||||
async function createTimeEntry(page, duration: string) {
|
async function createTimeEntry(page, duration: string) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page.getByTestId('time_entry_description').fill('Test time entry');
|
await page.getByTestId('time_entry_description').fill('Test time entry');
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
// Then create the time entry
|
// Then create the time entry
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
|
|||||||
duration: string
|
duration: string
|
||||||
) {
|
) {
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
// Open the dropdown menu and click "Manual time entry"
|
|
||||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
|
||||||
|
|
||||||
// Fill in the time entry details
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
|||||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
@@ -48,7 +47,6 @@ return [
|
|||||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||||
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ return [
|
|||||||
'organization' => 'The :attribute does not exist.',
|
'organization' => 'The :attribute does not exist.',
|
||||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||||
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
|
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
|
||||||
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
|
|
||||||
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
|
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
|
||||||
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
|
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
|
||||||
'task_name_already_exists' => 'A task with the same name already exists in the project.',
|
'task_name_already_exists' => 'A task with the same name already exists in the project.',
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const option = computed(() => ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 400,
|
fontWeight: 600,
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
margin: 16,
|
margin: 16,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||||
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
|
<div
|
||||||
|
:class="
|
||||||
|
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||||
|
">
|
||||||
<GroupedItemsCountButton
|
<GroupedItemsCountButton
|
||||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||||
:expanded="expanded"
|
:expanded="expanded"
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ onMounted(() => {
|
|||||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
userTimezone.value = getUserTimezone();
|
userTimezone.value = getUserTimezone();
|
||||||
|
|
||||||
const now = getDayJsInstance()();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
|
getDayJsInstance()().tz(timezone.value).format() !==
|
||||||
|
getDayJsInstance()().tz(userTimezone.value).format() &&
|
||||||
!hideTimezoneMismatchModal.value
|
!hideTimezoneMismatchModal.value
|
||||||
) {
|
) {
|
||||||
show.value = true;
|
show.value = true;
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ defineProps<{
|
|||||||
<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-b-background-separator">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p class="font-semibold text-sm text-text-primary">
|
||||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
|
|||||||
@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||||
import type {
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
CreateClientBody,
|
|
||||||
CreateProjectBody,
|
|
||||||
CreateTimeEntryBody,
|
|
||||||
Project,
|
|
||||||
Tag,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||||
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
|
||||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -60,8 +47,6 @@ const emit = defineEmits<{
|
|||||||
change: [];
|
change: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showManualTimeEntryModal = ref(false);
|
|
||||||
|
|
||||||
watch(isActive, () => {
|
watch(isActive, () => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
@@ -108,70 +93,14 @@ function switchToTimeEntryOrganization() {
|
|||||||
switchOrganization(currentTimeEntry.value.organization_id);
|
switchOrganization(currentTimeEntry.value.organization_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
async function createTag(tag: string) {
|
||||||
return await useTagsStore().createTag(tag);
|
return await useTagsStore().createTag(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
|
||||||
await useTimeEntriesStore().createTimeEntry(timeEntry);
|
|
||||||
showManualTimeEntryModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTimeEntryFromCurrentEntry() {
|
|
||||||
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
|
|
||||||
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
|
|
||||||
currentTimeEntryStore.$reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const deleteTimeEntryMutation = useMutation({
|
|
||||||
mutationFn: async (timeEntryId: string) => {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (!organizationId) {
|
|
||||||
throw new Error('No organization selected');
|
|
||||||
}
|
|
||||||
return await api.deleteTimeEntry(undefined, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
timeEntry: timeEntryId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
|
||||||
await useTimeEntriesStore().fetchTimeEntries();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function discardCurrentTimeEntry() {
|
|
||||||
if (currentTimeEntry.value.id) {
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
|
|
||||||
'Time entry discarded successfully',
|
|
||||||
'Failed to discard time entry'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tags } = storeToRefs(useTagsStore());
|
const { tags } = storeToRefs(useTagsStore());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TimeEntryCreateModal
|
|
||||||
v-model:show="showManualTimeEntryModal"
|
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:create-time-entry="createTimeEntry"
|
|
||||||
:projects
|
|
||||||
:tasks
|
|
||||||
:tags
|
|
||||||
:clients></TimeEntryCreateModal>
|
|
||||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||||
@@ -180,35 +109,24 @@ const { tags } = storeToRefs(useTagsStore());
|
|||||||
switchToTimeEntryOrganization
|
switchToTimeEntryOrganization
|
||||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||||
|
|
||||||
<div class="flex w-full items-center gap-2">
|
<TimeTrackerControls
|
||||||
<div class="flex w-full items-center gap-2">
|
v-model:current-time-entry="currentTimeEntry"
|
||||||
<div class="flex-1">
|
v-model:live-timer="now"
|
||||||
<TimeTrackerControls
|
:create-project
|
||||||
v-model:current-time-entry="currentTimeEntry"
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
v-model:live-timer="now"
|
:can-create-project="canCreateProjects()"
|
||||||
:create-project
|
:create-client
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
:clients
|
||||||
:can-create-project="canCreateProjects()"
|
:tags
|
||||||
:create-client
|
:tasks
|
||||||
:clients
|
:projects
|
||||||
:tags
|
:create-tag
|
||||||
:tasks
|
:is-active
|
||||||
:projects
|
:currency="getOrganizationCurrencyString()"
|
||||||
:create-tag
|
@start-live-timer="startLiveTimer"
|
||||||
:is-active
|
@stop-live-timer="stopLiveTimer"
|
||||||
:currency="getOrganizationCurrencyString()"
|
@start-timer="setActiveState(true)"
|
||||||
@start-live-timer="startLiveTimer"
|
@stop-timer="setActiveState(false)"
|
||||||
@stop-live-timer="stopLiveTimer"
|
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||||
@start-timer="setActiveState(true)"
|
|
||||||
@stop-timer="setActiveState(false)"
|
|
||||||
@update-time-entry="updateTimeEntry"
|
|
||||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
|
||||||
</div>
|
|
||||||
<TimeTrackerMoreOptionsDropdown
|
|
||||||
:has-active-timer="isActive"
|
|
||||||
@manual-entry="showManualTimeEntryModal = true"
|
|
||||||
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
type TimeEntryResponse,
|
type TimeEntryResponse,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
@@ -73,7 +73,6 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
|
|||||||
queries: {
|
queries: {
|
||||||
start: expandedDateRange.value.start!,
|
start: expandedDateRange.value.start!,
|
||||||
end: expandedDateRange.value.end!,
|
end: expandedDateRange.value.end!,
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -400,7 +400,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
|
||||||
:members="members"
|
:members="members"
|
||||||
show-date
|
show-date
|
||||||
show-member
|
show-member
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface FormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
const store = useOrganizationStore();
|
||||||
const { updateOrganization } = store;
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ const mutation = useMutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await fetchOrganization();
|
||||||
if (organization.value) {
|
if (organization.value) {
|
||||||
form.value = {
|
form.value = {
|
||||||
number_format: organization.value.number_format as NumberFormat,
|
number_format: organization.value.number_format as NumberFormat,
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
|
||||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
|
||||||
const { updateOrganization } = store;
|
|
||||||
const { organization } = storeToRefs(store);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
|
|
||||||
prevent_overlapping_time_entries: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
form.value.prevent_overlapping_time_entries =
|
|
||||||
organization.value?.prevent_overlapping_time_entries ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['organization'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
await mutation.mutateAsync({
|
|
||||||
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FormSection>
|
|
||||||
<template #title>Time Entry Settings</template>
|
|
||||||
<template #description>
|
|
||||||
Disallow overlapping time entries for members of this organization. When enabled, users
|
|
||||||
cannot create new time entries that overlap with their existing ones. This only affects
|
|
||||||
newly created entries.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #form>
|
|
||||||
<div class="col-span-6">
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="preventOverlappingTimeEntries"
|
|
||||||
v-model:checked="form.prevent_overlapping_time_entries" />
|
|
||||||
<InputLabel
|
|
||||||
for="preventOverlappingTimeEntries"
|
|
||||||
value="Prevent overlapping time entries (new entries only)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</FormSection>
|
|
||||||
</template>
|
|
||||||
@@ -8,25 +8,12 @@ import type { Permissions, Role } from '@/types/jetstream';
|
|||||||
import { canUpdateOrganization } from '@/utils/permissions';
|
import { canUpdateOrganization } from '@/utils/permissions';
|
||||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
team: Organization;
|
team: Organization;
|
||||||
availableRoles: Role[];
|
availableRoles: Role[];
|
||||||
permissions: Permissions;
|
permissions: Permissions;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(true);
|
|
||||||
const orgStore = useOrganizationStore();
|
|
||||||
const { organization } = storeToRefs(orgStore);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await orgStore.fetchOrganization();
|
|
||||||
loading.value = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,25 +26,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||||
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
|
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||||
Loading organization settings...
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
|
||||||
|
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
|
|
||||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
|
|
||||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||||
<SectionBorder />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { useElementVisibility } from '@vueuse/core';
|
import { useElementVisibility } from '@vueuse/core';
|
||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
@@ -22,6 +24,7 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||||
@@ -70,6 +73,7 @@ onMounted(async () => {
|
|||||||
await timeEntriesStore.fetchTimeEntries();
|
await timeEntriesStore.fetchTimeEntries();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showManualTimeEntryModal = ref(false);
|
||||||
const projectStore = useProjectsStore();
|
const projectStore = useProjectsStore();
|
||||||
const { projects } = storeToRefs(projectStore);
|
const { projects } = storeToRefs(projectStore);
|
||||||
const taskStore = useTasksStore();
|
const taskStore = useTasksStore();
|
||||||
@@ -101,9 +105,33 @@ function deleteSelected() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<TimeEntryCreateModal
|
||||||
|
v-model:show="showManualTimeEntryModal"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:create-time-entry="createTimeEntry"
|
||||||
|
:projects
|
||||||
|
:tasks
|
||||||
|
:tags
|
||||||
|
:clients></TimeEntryCreateModal>
|
||||||
<AppLayout title="Dashboard" data-testid="time_view">
|
<AppLayout title="Dashboard" data-testid="time_view">
|
||||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimeTracker></TimeTracker>
|
<div
|
||||||
|
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TimeTracker></TimeTracker>
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
|
||||||
|
<SecondaryButton
|
||||||
|
class="w-full text-center flex justify-center"
|
||||||
|
:icon="PlusIcon"
|
||||||
|
@click="showManualTimeEntryModal = true"
|
||||||
|
>Manual time entry
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TimeEntryMassActionRow
|
<TimeEntryMassActionRow
|
||||||
:selected-time-entries="selectedTimeEntries"
|
:selected-time-entries="selectedTimeEntries"
|
||||||
|
|||||||
@@ -36,14 +36,20 @@ const ClientResource = z
|
|||||||
const ClientCollection = z.array(ClientResource);
|
const ClientCollection = z.array(ClientResource);
|
||||||
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
const ClientUpdateRequest = z
|
const ClientUpdateRequest = z
|
||||||
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
is_archived: z.boolean().optional(),
|
||||||
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
||||||
const InvitationResource = z
|
const InvitationResource = z
|
||||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const InvitationStoreRequest = z
|
const InvitationStoreRequest = z
|
||||||
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
role: z.enum(['admin', 'manager', 'employee']),
|
||||||
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const InvoiceResource = z
|
const InvoiceResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -91,7 +97,6 @@ const InvoiceStoreRequest = z
|
|||||||
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
payment_iban: z.union([z.string(), z.null()]).optional(),
|
|
||||||
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
||||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
||||||
discount_type: InvoiceDiscountType.optional(),
|
discount_type: InvoiceDiscountType.optional(),
|
||||||
@@ -156,7 +161,6 @@ const DetailedInvoiceResource = z
|
|||||||
discount_type: z.string(),
|
discount_type: z.string(),
|
||||||
discount_amount: z.number().int(),
|
discount_amount: z.number().int(),
|
||||||
tax_rate: z.number().int(),
|
tax_rate: z.number().int(),
|
||||||
payment_iban: z.string(),
|
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
@@ -202,7 +206,6 @@ const InvoiceUpdateRequest = z
|
|||||||
billing_period_end: z.union([z.string(), z.null()]),
|
billing_period_end: z.union([z.string(), z.null()]),
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
payment_iban: z.union([z.string(), z.null()]),
|
|
||||||
tax_rate: z.number().int().gte(0).lte(2147483647),
|
tax_rate: z.number().int().gte(0).lte(2147483647),
|
||||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
||||||
discount_type: InvoiceDiscountType,
|
discount_type: InvoiceDiscountType,
|
||||||
@@ -317,7 +320,6 @@ const OrganizationResource = z
|
|||||||
is_personal: z.boolean(),
|
is_personal: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
currency_symbol: z.string(),
|
currency_symbol: z.string(),
|
||||||
number_format: NumberFormat,
|
number_format: NumberFormat,
|
||||||
@@ -332,7 +334,6 @@ const OrganizationUpdateRequest = z
|
|||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
|
||||||
number_format: NumberFormat,
|
number_format: NumberFormat,
|
||||||
currency_format: CurrencyFormat,
|
currency_format: CurrencyFormat,
|
||||||
date_format: DateFormat,
|
date_format: DateFormat,
|
||||||
@@ -387,7 +388,10 @@ const ProjectMemberResource = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectMemberStoreRequest = z
|
const ProjectMemberStoreRequest = z
|
||||||
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
|
.object({
|
||||||
|
member_id: z.string(),
|
||||||
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectMemberUpdateRequest = z
|
const ProjectMemberUpdateRequest = z
|
||||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||||
@@ -416,7 +420,6 @@ const TimeEntryAggregationType = z.enum([
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]);
|
]);
|
||||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||||
const Weekday = z.enum([
|
const Weekday = z.enum([
|
||||||
@@ -428,7 +431,6 @@ const Weekday = z.enum([
|
|||||||
'saturday',
|
'saturday',
|
||||||
'sunday',
|
'sunday',
|
||||||
]);
|
]);
|
||||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
|
||||||
const ReportStoreRequest = z
|
const ReportStoreRequest = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
@@ -451,8 +453,6 @@ const ReportStoreRequest = z
|
|||||||
history_group: TimeEntryAggregationTypeInterval,
|
history_group: TimeEntryAggregationTypeInterval,
|
||||||
week_start: Weekday.optional(),
|
week_start: Weekday.optional(),
|
||||||
timezone: z.union([z.string(), z.null()]).optional(),
|
timezone: z.union([z.string(), z.null()]).optional(),
|
||||||
rounding_type: TimeEntryRoundingType.optional(),
|
|
||||||
rounding_minutes: z.union([z.number(), z.null()]).optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
})
|
})
|
||||||
@@ -479,8 +479,6 @@ const DetailedReportResource = z
|
|||||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
rounding_type: z.union([z.string(), z.null()]),
|
|
||||||
rounding_minutes: z.union([z.number(), z.null()]),
|
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
@@ -594,7 +592,12 @@ const DetailedWithDataReportResource = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagResource = z
|
const TagResource = z
|
||||||
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagCollection = z.array(TagResource);
|
const TagCollection = z.array(TagResource);
|
||||||
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
@@ -626,7 +629,6 @@ const TaskUpdateRequest = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const start = z.union([z.string(), z.null()]).optional();
|
const start = z.union([z.string(), z.null()]).optional();
|
||||||
const rounding_minutes = z.union([z.number(), z.null()]).optional();
|
|
||||||
const TimeEntryResource = z
|
const TimeEntryResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -747,7 +749,6 @@ export const schemas = {
|
|||||||
TimeEntryAggregationType,
|
TimeEntryAggregationType,
|
||||||
TimeEntryAggregationTypeInterval,
|
TimeEntryAggregationTypeInterval,
|
||||||
Weekday,
|
Weekday,
|
||||||
TimeEntryRoundingType,
|
|
||||||
ReportStoreRequest,
|
ReportStoreRequest,
|
||||||
DetailedReportResource,
|
DetailedReportResource,
|
||||||
ReportUpdateRequest,
|
ReportUpdateRequest,
|
||||||
@@ -760,7 +761,6 @@ export const schemas = {
|
|||||||
TaskStoreRequest,
|
TaskStoreRequest,
|
||||||
TaskUpdateRequest,
|
TaskUpdateRequest,
|
||||||
start,
|
start,
|
||||||
rounding_minutes,
|
|
||||||
TimeEntryResource,
|
TimeEntryResource,
|
||||||
TimeEntryStoreRequest,
|
TimeEntryStoreRequest,
|
||||||
TimeEntryUpdateMultipleRequest,
|
TimeEntryUpdateMultipleRequest,
|
||||||
@@ -790,7 +790,13 @@ const endpoints = makeApi([
|
|||||||
alias: 'getCurrencies',
|
alias: 'getCurrencies',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
response: z.array(
|
response: z.array(
|
||||||
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
|
z
|
||||||
|
.object({
|
||||||
|
code: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
symbol: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -862,7 +868,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1157,7 +1166,13 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.array(
|
response: z.array(
|
||||||
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
|
z
|
||||||
|
.object({
|
||||||
|
value: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
),
|
),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
@@ -1220,7 +1235,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1263,7 +1281,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1311,7 +1332,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1339,7 +1363,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1377,7 +1405,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1433,7 +1465,7 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
schema: z.union([
|
schema: z.union([
|
||||||
z.object({ message: z.string() }).passthrough(),
|
z.object({ message: z.string() }).passthrough(),
|
||||||
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
|
z.object({ message: z.string() }).passthrough(),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1455,7 +1487,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1476,7 +1511,11 @@ const endpoints = makeApi([
|
|||||||
.object({
|
.object({
|
||||||
data: z.array(
|
data: z.array(
|
||||||
z
|
z
|
||||||
.object({ key: z.string(), name: z.string(), description: z.string() })
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -1564,7 +1603,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1592,7 +1634,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1614,7 +1660,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1760,7 +1809,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1803,7 +1855,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1846,7 +1901,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1930,7 +1988,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1995,13 +2056,6 @@ const endpoints = makeApi([
|
|||||||
],
|
],
|
||||||
response: z.object({ download_link: z.string() }).passthrough(),
|
response: z.object({ download_link: z.string() }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -2021,7 +2075,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2045,13 +2102,6 @@ const endpoints = makeApi([
|
|||||||
],
|
],
|
||||||
response: z.object({ download_link: z.string() }).passthrough(),
|
response: z.object({ download_link: z.string() }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -2097,7 +2147,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2119,7 +2173,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2189,7 +2246,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2222,7 +2282,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2244,7 +2308,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2277,7 +2344,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2299,7 +2370,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2327,7 +2401,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2370,7 +2448,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2433,7 +2515,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2549,7 +2634,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2592,7 +2680,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2676,7 +2767,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2704,7 +2798,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2820,7 +2918,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2842,7 +2944,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2948,7 +3053,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3032,7 +3140,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3142,7 +3253,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3190,7 +3304,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3218,7 +3335,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3313,7 +3434,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3356,7 +3480,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3404,7 +3531,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3432,7 +3562,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3505,16 +3639,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'rounding_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rounding_minutes',
|
|
||||||
type: 'Query',
|
|
||||||
schema: rounding_minutes,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'user_id',
|
name: 'user_id',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -3572,7 +3696,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3600,7 +3727,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3622,7 +3753,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3665,7 +3799,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3708,7 +3845,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3741,7 +3881,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3763,7 +3907,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3833,7 +3980,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
},
|
},
|
||||||
@@ -3852,7 +3998,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
},
|
},
|
||||||
@@ -3891,16 +4036,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'rounding_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rounding_minutes',
|
|
||||||
type: 'Query',
|
|
||||||
schema: rounding_minutes,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -3985,7 +4120,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4020,7 +4158,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4037,7 +4174,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4085,16 +4221,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'rounding_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rounding_minutes',
|
|
||||||
type: 'Query',
|
|
||||||
schema: rounding_minutes,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4130,7 +4256,11 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4152,7 +4282,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4213,16 +4346,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'rounding_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rounding_minutes',
|
|
||||||
type: 'Query',
|
|
||||||
schema: rounding_minutes,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4253,7 +4376,11 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4275,7 +4402,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4357,7 +4487,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4374,7 +4508,10 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4397,7 +4534,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4435,7 +4576,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
import { computed, inject, type ComputedRef } from 'vue';
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
date: Dayjs;
|
date: Date;
|
||||||
totalMinutes?: number;
|
totalMinutes?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-day-header-custom">
|
<div class="fc-day-header-custom">
|
||||||
<div class="text-xs text-muted-foreground font-medium">
|
<div class="text-xs text-muted-foreground font-medium">
|
||||||
{{ date.format('ddd') }}
|
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
|
||||||
</div>
|
</div>
|
||||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const calendarOptions = computed(() => ({
|
|||||||
editable: true,
|
editable: true,
|
||||||
eventResizableFromStart: true,
|
eventResizableFromStart: true,
|
||||||
eventDurationEditable: true,
|
eventDurationEditable: true,
|
||||||
timeZone: getUserTimezone(),
|
timeZone: 'America/Adak',
|
||||||
eventStartEditable: true,
|
eventStartEditable: true,
|
||||||
select: handleDateSelect,
|
select: handleDateSelect,
|
||||||
eventClick: handleEventClick,
|
eventClick: handleEventClick,
|
||||||
@@ -332,16 +332,9 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
</template>
|
</template>
|
||||||
<template #dayHeaderContent="arg">
|
<template #dayHeaderContent="arg">
|
||||||
<FullCalendarDayHeader
|
<FullCalendarDayHeader
|
||||||
:date="
|
:date="arg.date"
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
|
||||||
"
|
|
||||||
:total-minutes="
|
:total-minutes="
|
||||||
dailyTotals[
|
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
||||||
getDayJsInstance()(arg.date)
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.format('YYYY-MM-DD')
|
|
||||||
] || 0
|
|
||||||
" />
|
" />
|
||||||
</template>
|
</template>
|
||||||
</FullCalendar>
|
</FullCalendar>
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
|
|||||||
data-testid="time_picker_input"
|
data-testid="time_picker_input"
|
||||||
type="text"
|
type="text"
|
||||||
@blur="updateTime"
|
@blur="updateTime"
|
||||||
@keydown.enter.prevent="updateTime"
|
|
||||||
@focus="($event.target as HTMLInputElement).select()"
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
@mouseup="($event.target as HTMLInputElement).select()"
|
@mouseup="($event.target as HTMLInputElement).select()"
|
||||||
@click="($event.target as HTMLInputElement).select()"
|
@click="($event.target as HTMLInputElement).select()"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, nextTick, ref, watch } from 'vue';
|
import { defineProps, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useFocusWithin } from '@vueuse/core';
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||||
import { Button } from '@/Components/ui/button';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
start: string;
|
start: string;
|
||||||
@@ -17,42 +17,31 @@ const emit = defineEmits(['changed', 'close']);
|
|||||||
|
|
||||||
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
||||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||||
const showEndTimePicker = ref(false);
|
|
||||||
|
|
||||||
watch(props, () => {
|
watch(props, () => {
|
||||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||||
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
||||||
showEndTimePicker.value = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateTimeEntry() {
|
function updateTimeEntry() {
|
||||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||||
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
||||||
|
|
||||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||||
emit(
|
emit(
|
||||||
'changed',
|
'changed',
|
||||||
getDayJsInstance()(tempStart.value).utc().format(),
|
getDayJsInstance()(tempStart.value).utc().format(),
|
||||||
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
|
getDayJsInstance()(tempEnd.value).utc().format()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEndTime() {
|
|
||||||
showEndTimePicker.value = true;
|
|
||||||
tempEnd.value = getDayJsInstance()().format();
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmEndTime() {
|
|
||||||
// wait for the v-model for the end time to update
|
|
||||||
nextTick(() => {
|
|
||||||
updateTimeEntry();
|
|
||||||
showEndTimePicker.value = false;
|
|
||||||
emit('close');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownContent = ref();
|
const dropdownContent = ref();
|
||||||
|
const { focused } = useFocusWithin(dropdownContent);
|
||||||
|
|
||||||
|
watch(focused, (newValue, oldValue) => {
|
||||||
|
if (oldValue === true && newValue === false) {
|
||||||
|
updateTimeEntry();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -78,7 +67,7 @@ const dropdownContent = ref();
|
|||||||
</div>
|
</div>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
||||||
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
|
<div v-if="tempEnd !== null" class="space-y-2">
|
||||||
<TimePickerSimple
|
<TimePickerSimple
|
||||||
v-model="tempEnd"
|
v-model="tempEnd"
|
||||||
data-testid="time_entry_range_end"
|
data-testid="time_entry_range_end"
|
||||||
@@ -88,22 +77,6 @@ const dropdownContent = ref();
|
|||||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||||
@changed="updateTimeEntry"></DatePicker>
|
@changed="updateTimeEntry"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="end === null && !showEndTimePicker">
|
|
||||||
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
|
|
||||||
<TimePickerSimple
|
|
||||||
v-model="tempEnd"
|
|
||||||
data-testid="time_entry_range_end"
|
|
||||||
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="tempEnd"
|
|
||||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
|
||||||
@keydown.enter.prevent="confirmEndTime"></DatePicker>
|
|
||||||
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-text-secondary">-- : --</div>
|
<div v-else class="text-text-secondary">-- : --</div>
|
||||||
<div tabindex="0" @focusin="emit('close')"></div>
|
<div tabindex="0" @focusin="emit('close')"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const props = defineProps<{
|
|||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
onStartStopClick: (timeEntry: TimeEntry) => void;
|
onStartStopClick: (timeEntry: TimeEntry) => void;
|
||||||
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
|
|
||||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||||
@@ -174,7 +173,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
:show-edit="false"
|
:show-edit="false"
|
||||||
:show-duplicate="false"
|
|
||||||
@delete="
|
@delete="
|
||||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||||
"></TimeEntryMoreOptionsDropdown>
|
"></TimeEntryMoreOptionsDropdown>
|
||||||
@@ -204,7 +202,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
||||||
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
||||||
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
||||||
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
|
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:create-tag
|
:create-tag
|
||||||
:time-entry="subEntry"
|
:time-entry="subEntry"
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => editableTimeEntry.value?.project_id,
|
||||||
|
(value) => {
|
||||||
|
if (value && editableTimeEntry.value) {
|
||||||
|
// check if project is billable by default and set billable accordingly
|
||||||
|
const project = props.projects.find((p) => p.id === value);
|
||||||
|
if (project) {
|
||||||
|
editableTimeEntry.value.billable = project.is_billable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const localStart = computed({
|
const localStart = computed({
|
||||||
get: () =>
|
get: () =>
|
||||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
|
|||||||
tags: [...entry.tags],
|
tags: [...entry.tags],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumDuration(timeEntries: TimeEntry[]) {
|
function sumDuration(timeEntries: TimeEntry[]) {
|
||||||
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||||
}
|
}
|
||||||
@@ -159,7 +158,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:clients
|
:clients
|
||||||
:on-start-stop-click="startTimeEntryFromExisting"
|
:on-start-stop-click="startTimeEntryFromExisting"
|
||||||
:duplicate-time-entry="createTimeEntry"
|
|
||||||
:update-time-entries
|
:update-time-entries
|
||||||
:update-time-entry
|
:update-time-entry
|
||||||
:delete-time-entries
|
:delete-time-entries
|
||||||
@@ -200,7 +198,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
|||||||
:update-time-entry
|
:update-time-entry
|
||||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:time-entry="entry.timeEntries[0]"
|
:time-entry="entry.timeEntries[0]"
|
||||||
@selected="selectedTimeEntries.push(entry)"
|
@selected="selectedTimeEntries.push(entry)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showEdit?: boolean;
|
showEdit?: boolean;
|
||||||
showDuplicate?: boolean;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showDuplicate: true,
|
|
||||||
showEdit: true,
|
showEdit: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -21,7 +19,6 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [];
|
edit: [];
|
||||||
delete: [];
|
delete: [];
|
||||||
duplicate: [];
|
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -54,14 +51,6 @@ const emit = defineEmits<{
|
|||||||
<PencilIcon class="w-5" />
|
<PencilIcon class="w-5" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.showDuplicate"
|
|
||||||
data-testid="time_entry_duplicate"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('duplicate')">
|
|
||||||
<DocumentDuplicateIcon class="w-5" />
|
|
||||||
<span>Duplicate</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="time_entry_delete"
|
data-testid="time_entry_delete"
|
||||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const props = defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
onStartStopClick: () => void;
|
onStartStopClick: () => void;
|
||||||
deleteTimeEntry: () => void;
|
deleteTimeEntry: () => void;
|
||||||
duplicateTimeEntry?: () => void;
|
|
||||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||||
currency: string;
|
currency: string;
|
||||||
showMember?: boolean;
|
showMember?: boolean;
|
||||||
@@ -167,7 +166,6 @@ async function handleDeleteTimeEntry() {
|
|||||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@duplicate="duplicateTimeEntry"
|
|
||||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const open = ref(false);
|
|||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const defaultUnit =
|
const defaultUnit =
|
||||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
let newEndDate = props.end;
|
let newEndDate = props.end;
|
||||||
let newStartDate = props.start;
|
let newStartDate = props.start;
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ const emit = defineEmits<{
|
|||||||
updateTimeEntry: [];
|
updateTimeEntry: [];
|
||||||
startLiveTimer: [];
|
startLiveTimer: [];
|
||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
createTimeEntry: [];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function updateProject() {
|
function updateProject() {
|
||||||
@@ -281,7 +280,6 @@ useSelectEvents(
|
|||||||
@stop-live-timer="emit('stopLiveTimer')"
|
@stop-live-timer="emit('stopLiveTimer')"
|
||||||
@update-timer="emit('updateTimeEntry')"
|
@update-timer="emit('updateTimeEntry')"
|
||||||
@start-timer="emit('startTimer')"
|
@start-timer="emit('startTimer')"
|
||||||
@create-time-entry="emit('createTimeEntry')"
|
|
||||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
hasActiveTimer: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
manualEntry: [];
|
|
||||||
discard: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<button
|
|
||||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
|
|
||||||
aria-label="Time entry actions">
|
|
||||||
<svg
|
|
||||||
class="h-8 w-8 p-1 rounded-full"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="1.5"
|
|
||||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('manualEntry')">
|
|
||||||
<PlusIcon class="w-5" />
|
|
||||||
<span>Manual time entry</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.hasActiveTimer"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
@click="emit('discard')">
|
|
||||||
<XMarkIcon class="w-5" />
|
|
||||||
<span>Discard</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -16,7 +16,6 @@ const emit = defineEmits<{
|
|||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
updateTimer: [];
|
updateTimer: [];
|
||||||
startTimer: [];
|
startTimer: [];
|
||||||
createTimeEntry: [];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
@@ -56,7 +55,7 @@ const currentTime = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||||
|
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
const newStartDate = dayjs().subtract(seconds, 's');
|
const newStartDate = dayjs().subtract(seconds, 's');
|
||||||
@@ -74,16 +73,12 @@ function updateTimerAndStartLiveTimerUpdate() {
|
|||||||
|
|
||||||
const temporaryCustomTimerEntry = ref<string>('');
|
const temporaryCustomTimerEntry = ref<string>('');
|
||||||
|
|
||||||
async function updateTimeRange(newStart: string, newEnd: string | null) {
|
async function updateTimeRange(newStart: string) {
|
||||||
// prohibit updates in the future
|
// prohibit updates in the future
|
||||||
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
||||||
currentTimeEntry.value.start = newStart;
|
currentTimeEntry.value.start = newStart;
|
||||||
currentTimeEntry.value.end = newEnd;
|
|
||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
emit('updateTimer');
|
emit('updateTimer');
|
||||||
} else if (newEnd !== null) {
|
|
||||||
// If there's no ID but we have both start and end, create a new time entry
|
|
||||||
emit('createTimeEntry');
|
|
||||||
} else {
|
} else {
|
||||||
emit('startTimer');
|
emit('startTimer');
|
||||||
}
|
}
|
||||||
@@ -96,21 +91,11 @@ const startTime = computed(() => {
|
|||||||
}
|
}
|
||||||
return dayjs().utc().format();
|
return dayjs().utc().format();
|
||||||
});
|
});
|
||||||
|
|
||||||
const endTime = computed(() => {
|
|
||||||
if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {
|
|
||||||
return currentTimeEntry.value.end;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputField = ref<HTMLInputElement | null>(null);
|
const inputField = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const timeRangeSelector = ref<HTMLElement | null>(null);
|
const timeRangeSelector = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function openModalOnTab(e: FocusEvent) {
|
function openModalOnTab(e: FocusEvent) {
|
||||||
pauseLiveTimerUpdate(e);
|
|
||||||
|
|
||||||
// check if the source is inside the dropdown
|
// check if the source is inside the dropdown
|
||||||
const source = e.relatedTarget as HTMLElement;
|
const source = e.relatedTarget as HTMLElement;
|
||||||
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
||||||
@@ -118,12 +103,6 @@ function openModalOnTab(e: FocusEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModalOnClick(e: MouseEvent) {
|
|
||||||
pauseLiveTimerUpdate(e);
|
|
||||||
|
|
||||||
open.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusNextElement(e: KeyboardEvent) {
|
function focusNextElement(e: KeyboardEvent) {
|
||||||
if (open.value) {
|
if (open.value) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -156,8 +135,8 @@ function closeAndFocusInput() {
|
|||||||
data-testid="time_entry_time"
|
data-testid="time_entry_time"
|
||||||
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
|
@focus="pauseLiveTimerUpdate"
|
||||||
@focusin="openModalOnTab"
|
@focusin="openModalOnTab"
|
||||||
@click="openModalOnClick"
|
|
||||||
@keydown.exact.tab="focusNextElement"
|
@keydown.exact.tab="focusNextElement"
|
||||||
@keydown.exact.shift.tab="open = false"
|
@keydown.exact.shift.tab="open = false"
|
||||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||||
@@ -167,7 +146,7 @@ function closeAndFocusInput() {
|
|||||||
<div ref="timeRangeSelector">
|
<div ref="timeRangeSelector">
|
||||||
<TimeRangeSelector
|
<TimeRangeSelector
|
||||||
:start="startTime"
|
:start="startTime"
|
||||||
:end="endTime"
|
:end="null"
|
||||||
@changed="updateTimeRange"
|
@changed="updateTimeRange"
|
||||||
@close="closeAndFocusInput">
|
@close="closeAndFocusInput">
|
||||||
</TimeRangeSelector>
|
</TimeRangeSelector>
|
||||||
|
|||||||
@@ -208,30 +208,22 @@ export function formatStartEnd(
|
|||||||
export function parseTimeInput(
|
export function parseTimeInput(
|
||||||
input: string,
|
input: string,
|
||||||
defaultUnit: TimeInputUnit = 'minutes'
|
defaultUnit: TimeInputUnit = 'minutes'
|
||||||
): number | null {
|
): {
|
||||||
|
seconds: number | null;
|
||||||
|
isHHMM: boolean;
|
||||||
|
} {
|
||||||
// Check if input is a decimal number (hours)
|
// Check if input is a decimal number (hours)
|
||||||
const decimalRegex = /^-?\d+[.,]\d+$/;
|
const decimalRegex = /^-?\d+[.,]\d+$/;
|
||||||
if (decimalRegex.test(input)) {
|
if (decimalRegex.test(input)) {
|
||||||
const hours = parseFloat(input.replace(',', '.'));
|
const hours = parseFloat(input.replace(',', '.'));
|
||||||
return Math.round(hours * 3600);
|
return { seconds: Math.round(hours * 3600), isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is just a number (minutes or hours based on defaultUnit)
|
// Check if input is just a number (minutes or hours based on defaultUnit)
|
||||||
if (/^-?\d+$/.test(input)) {
|
if (/^-?\d+$/.test(input)) {
|
||||||
const value = parseInt(input);
|
const value = parseInt(input);
|
||||||
return defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
||||||
}
|
return { seconds, isHHMM: false };
|
||||||
|
|
||||||
// Check if input is in HH:MM:SS format
|
|
||||||
const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/;
|
|
||||||
if (HHMMSStimeRegex.test(input)) {
|
|
||||||
const match = input.match(HHMMSStimeRegex);
|
|
||||||
if (match) {
|
|
||||||
const hours = parseInt(match[1]);
|
|
||||||
const minutes = parseInt(match[2]);
|
|
||||||
const seconds = parseInt(match[3]);
|
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is in HH:MM format
|
// Check if input is in HH:MM format
|
||||||
@@ -241,15 +233,15 @@ export function parseTimeInput(
|
|||||||
if (match) {
|
if (match) {
|
||||||
const hours = parseInt(match[1]);
|
const hours = parseInt(match[1]);
|
||||||
const minutes = parseInt(match[2]);
|
const minutes = parseInt(match[2]);
|
||||||
return (hours * 60 + minutes) * 60;
|
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse natural language like "1h 30m"
|
// Try to parse natural language like "1h 30m"
|
||||||
const parsedDuration = parse(input, 's');
|
const parsedDuration = parse(input, 's');
|
||||||
if (parsedDuration && parsedDuration > 0) {
|
if (parsedDuration && parsedDuration > 0) {
|
||||||
return parsedDuration;
|
return { seconds: parsedDuration, isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return { seconds: null, isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
task_id: currentTimeEntry.value.task_id,
|
task_id: currentTimeEntry.value.task_id,
|
||||||
start: currentTimeEntry.value.start,
|
start: currentTimeEntry.value.start,
|
||||||
billable: currentTimeEntry.value.billable,
|
billable: currentTimeEntry.value.billable,
|
||||||
end: currentTimeEntry.value.end,
|
end: null,
|
||||||
tags: currentTimeEntry.value.tags,
|
tags: currentTimeEntry.value.tags,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -175,12 +175,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
'Time entry updated!'
|
'Time entry updated!'
|
||||||
);
|
);
|
||||||
if (response?.data) {
|
if (response?.data) {
|
||||||
if (response.data.end === null) {
|
currentTimeEntry.value = response.data;
|
||||||
currentTimeEntry.value = response.data;
|
|
||||||
} else {
|
|
||||||
$reset();
|
|
||||||
stopLiveTimer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -220,6 +215,5 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
stopLiveTimer,
|
stopLiveTimer,
|
||||||
now,
|
now,
|
||||||
setActiveState,
|
setActiveState,
|
||||||
$reset,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,19 +12,11 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
|
||||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
|
||||||
export type GroupingOption =
|
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
|
||||||
| 'project'
|
|
||||||
| 'task'
|
|
||||||
| 'user'
|
|
||||||
| 'billable'
|
|
||||||
| 'client'
|
|
||||||
| 'description'
|
|
||||||
| 'tag';
|
|
||||||
|
|
||||||
export const useReportingStore = defineStore('reporting', () => {
|
export const useReportingStore = defineStore('reporting', () => {
|
||||||
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||||
@@ -81,7 +73,6 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
billable: 'Non-Billable',
|
billable: 'Non-Billable',
|
||||||
client: 'No Client',
|
client: 'No Client',
|
||||||
description: 'No Description',
|
description: 'No Description',
|
||||||
tag: 'No Tag',
|
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
|
|
||||||
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
||||||
@@ -115,11 +106,6 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
const { clients } = storeToRefs(clientsStore);
|
const { clients } = storeToRefs(clientsStore);
|
||||||
return clients.value.find((client) => client.id === key)?.name;
|
return clients.value.find((client) => client.id === key)?.name;
|
||||||
}
|
}
|
||||||
if (type === 'tag') {
|
|
||||||
const tagsStore = useTagsStore();
|
|
||||||
const { tags } = storeToRefs(tagsStore);
|
|
||||||
return tags.value.find((tag) => tag.id === key)?.name;
|
|
||||||
}
|
|
||||||
if (type === 'billable') {
|
if (type === 'billable') {
|
||||||
if (key === '0') {
|
if (key === '0') {
|
||||||
return 'Non-Billable';
|
return 'Non-Billable';
|
||||||
@@ -165,11 +151,6 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
value: 'description',
|
value: 'description',
|
||||||
icon: DocumentTextIcon,
|
icon: DocumentTextIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Tags',
|
|
||||||
value: 'tag',
|
|
||||||
icon: DocumentTextIcon,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Console;
|
|
||||||
|
|
||||||
use App\Console\Kernel;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
#[CoversClass(Kernel::class)]
|
|
||||||
class KernelTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_self_host_commands_schedule_time_is_consistent_with_app_key(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config([
|
|
||||||
'app.key' => 'base64:cOXN4GLMXYjcdG0fKosnFogofXw1pNoXkLAViRH+a5Y=',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$schedule1 = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$firstRunEvents = collect($schedule1->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
|
|
||||||
str_contains($event->command, 'self-host:telemetry')
|
|
||||||
);
|
|
||||||
|
|
||||||
$schedule2 = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$secondRunEvents = collect($schedule2->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
|
|
||||||
str_contains($event->command, 'self-host:telemetry')
|
|
||||||
);
|
|
||||||
config([
|
|
||||||
'app.key' => 'base64:eP58hkQ8l3guqf8wvWJR7pB0weVQtnpjMdYpaVwX4Jw=',
|
|
||||||
]);
|
|
||||||
$schedule3 = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$thirdRunEvents = collect($schedule3->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
|
|
||||||
str_contains($event->command, 'self-host:telemetry')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(2, $firstRunEvents);
|
|
||||||
$this->assertCount(2, $secondRunEvents);
|
|
||||||
$this->assertCount(2, $thirdRunEvents);
|
|
||||||
|
|
||||||
foreach ($firstRunEvents as $index => $event) {
|
|
||||||
$this->assertSame('52 9,21 * * *', $firstRunEvents[$index]->expression);
|
|
||||||
$this->assertSame('52 9,21 * * *', $secondRunEvents[$index]->expression);
|
|
||||||
$this->assertSame('48 13,1 * * *', $thirdRunEvents[$index]->expression);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_self_hosting_telemetry_can_be_activated(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config([
|
|
||||||
'scheduling.tasks.self_hosting_telemetry' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(1, $events);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_self_hosting_telemetry_can_be_deactivated(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config([
|
|
||||||
'scheduling.tasks.self_hosting_telemetry' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(0, $events);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_self_hosting_check_for_update_can_be_activated(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config([
|
|
||||||
'scheduling.tasks.self_hosting_check_for_update' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(1, $events);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_self_hosting_check_for_update_can_be_deactivated(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
config([
|
|
||||||
'scheduling.tasks.self_hosting_check_for_update' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
|
|
||||||
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertCount(0, $events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
]);
|
]);
|
||||||
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
|
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
@@ -58,43 +57,11 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_returns_list_of_clients_assigned_to_employee_user(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'clients:view',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$clients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
|
||||||
$projectWithMembership1 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(0))->addMember($data->member)->isPrivate()->create();
|
|
||||||
$projectWithMembership2 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(1))->addMember($data->member)->isPrivate()->create();
|
|
||||||
|
|
||||||
$otherClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
|
||||||
$projectWithoutMembership = Project::factory()->forOrganization($data->organization)->forClient($otherClients->get(0))->isPrivate()->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertJsonCount(2, 'data');
|
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
|
||||||
->has('data')
|
|
||||||
->has('links')
|
|
||||||
->has('meta')
|
|
||||||
->count('data', 2)
|
|
||||||
->where('data.0.id', $clients->get(0)->getKey())
|
|
||||||
->where('data.1.id', $clients->get(1)->getKey())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
|
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
]);
|
]);
|
||||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||||
@@ -114,7 +81,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
]);
|
]);
|
||||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||||
@@ -137,7 +103,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
]);
|
]);
|
||||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||||
@@ -160,7 +125,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
]);
|
]);
|
||||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||||
|
|||||||
@@ -3393,241 +3393,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
|
|
||||||
'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(),
|
|
||||||
'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
|
|
||||||
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
|
|
||||||
$this->travelTo($now);
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:create:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $now->copy()->subHour(),
|
|
||||||
'end' => null,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'billable' => true,
|
|
||||||
'start' => $now->copy()->addDay()->toIso8601ZuluString(),
|
|
||||||
'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:update:own',
|
|
||||||
]);
|
|
||||||
$data->organization->prevent_overlapping_time_entries = true;
|
|
||||||
$data->organization->save();
|
|
||||||
$baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC');
|
|
||||||
$baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC');
|
|
||||||
$base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseStart,
|
|
||||||
'end' => $baseEnd,
|
|
||||||
]);
|
|
||||||
$toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
|
|
||||||
->create([
|
|
||||||
'start' => $baseEnd->copy()->addMinutes(30),
|
|
||||||
'end' => $baseEnd->copy()->addHour(),
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [
|
|
||||||
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'overlapping_time_entry',
|
|
||||||
'message' => 'Overlapping time entries are not allowed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void
|
public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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;
|
||||||
use App\Models\Tag;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -1008,201 +1007,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
],
|
],
|
||||||
], $result);
|
], $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_aggregate_time_entries_group_by_tag_includes_no_tag_and_avoids_double_counting_overall(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$tag1 = Tag::factory()->create();
|
|
||||||
$tag2 = Tag::factory()->create();
|
|
||||||
$start = Carbon::now();
|
|
||||||
|
|
||||||
// One entry with two tags (100s)
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 100)->create([
|
|
||||||
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
|
||||||
]);
|
|
||||||
// One entry with one tag (50s)
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 50)->create([
|
|
||||||
'tags' => [$tag1->getKey()],
|
|
||||||
]);
|
|
||||||
// One entry with no tags (25s)
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 25)->create([
|
|
||||||
'tags' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Tag,
|
|
||||||
null,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert - overall total should be 175 and groups: null=25, tag1=150, tag2=100
|
|
||||||
$expected = [
|
|
||||||
'seconds' => 175,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'tag',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 25,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $tag1->getKey(),
|
|
||||||
'seconds' => 150,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $tag2->getKey(),
|
|
||||||
'seconds' => 100,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$this->assertEqualsCanonicalizing($expected, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$tag1 = Tag::factory()->create();
|
|
||||||
$tag2 = Tag::factory()->create();
|
|
||||||
$start = Carbon::now();
|
|
||||||
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 120)->forProject($project)->create([
|
|
||||||
'tags' => [$tag1->getKey()],
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 60)->forProject($project)->create([
|
|
||||||
'tags' => [$tag2->getKey()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
TimeEntryAggregationType::Tag,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$expected = [
|
|
||||||
'seconds' => 180,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project->getKey(),
|
|
||||||
'seconds' => 180,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'tag',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $tag1->getKey(),
|
|
||||||
'seconds' => 120,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $tag2->getKey(),
|
|
||||||
'seconds' => 60,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$this->assertEqualsCanonicalizing($expected, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag_avoids_double_counting(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$tag1 = Tag::factory()->create();
|
|
||||||
$tag2 = Tag::factory()->create();
|
|
||||||
$start = Carbon::now();
|
|
||||||
|
|
||||||
// One entry with two tags => subgroup rows show both tags, but project total should equal entry duration
|
|
||||||
TimeEntry::factory()->startWithDuration($start, 100)->forProject($project)->create([
|
|
||||||
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
TimeEntryAggregationType::Tag,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$expected = [
|
|
||||||
'seconds' => 100,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project->getKey(),
|
|
||||||
'seconds' => 100,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => 'tag',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $tag1->getKey(),
|
|
||||||
'seconds' => 100,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $tag2->getKey(),
|
|
||||||
'seconds' => 100,
|
|
||||||
'cost' => 0,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$this->assertEqualsCanonicalizing($expected, $result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user