Compare commits

...

4 Commits

Author SHA1 Message Date
Gregor Vostrak
fb41d60a21 fix flakyness in e2e tests for reporting 2025-07-17 18:26:11 +02:00
Gregor Vostrak
6a740015b7 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:26:11 +02:00
Gregor Vostrak
be873c72fe add rounding frontend to reports, and support for shared reports 2025-07-17 18:26:11 +02:00
Constantin Graf
bbfa411f32 Add rounding feature 2025-07-17 18:26:11 +02:00
57 changed files with 5309 additions and 125 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryRoundingType: string
{
use LaravelEnumHelper;
case Up = 'up';
case Down = 'down';
case Nearest = 'nearest';
}

View File

@@ -73,7 +73,9 @@ class ReportController extends Controller
false, false,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
); );
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(), $timeEntriesQuery->clone(),
@@ -84,7 +86,9 @@ class ReportController extends Controller
true, true,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
); );
return new DetailedWithDataReportResource($report, $data, $historyData); return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -107,6 +107,8 @@ class ReportController extends Controller
} }
} }
$properties->timezone = $timezone; $properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$report->properties = $properties; $report->properties = $properties;
if ($isPublic) { if ($isPublic) {
$report->share_secret = $reportService->generateSecret(); $report->share_secret = $reportService->generateSecret();

View File

@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored; use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse; use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
@@ -140,8 +142,15 @@ class TimeEntryController extends Controller
*/ */
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{ {
$select = TimeEntry::SELECT_COLUMNS;
if ($request->getRoundingType() !== null && $request->getRoundingMinutes() !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as end');
}
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc'); ->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery); $filter = new TimeEntryFilter($timeEntriesQuery);
@@ -183,6 +192,8 @@ class TimeEntryController extends Controller
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
@@ -207,8 +218,9 @@ class TimeEntryController extends Controller
if ($viewFile === false) { if ($viewFile === false) {
throw new \LogicException('View file not found'); throw new \LogicException('View file not found');
} }
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]), $timeEntriesAggregateQuery,
null, null,
null, null,
$user->timezone, $user->timezone,
@@ -216,7 +228,9 @@ class TimeEntryController extends Controller
false, false,
null, null,
null, null,
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes,
); );
$html = Blade::render($viewFile, [ $html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(), 'timeEntries' => $timeEntriesQuery->get(),
@@ -324,6 +338,8 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery, $timeEntriesAggregateQuery,
@@ -334,7 +350,9 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
return [ return [
@@ -373,6 +391,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -383,7 +403,9 @@ class TimeEntryController extends Controller
false, false,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -394,7 +416,9 @@ class TimeEntryController extends Controller
true, true,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -477,7 +501,7 @@ class TimeEntryController extends Controller
/** /**
* @return Builder<TimeEntry> * @return Builder<TimeEntry>
*/ */
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{ {
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
'nullable', 'nullable',
'timezone:all', 'timezone:all',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'properties.rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'properties.rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
{ {
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
} }
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
}
public function getPropertyRoundingMinutes(): ?int
{
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
return null;
}
return (int) $this->input('properties.rounding_minutes');
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{ {
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
{ {
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{ {
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -11,8 +12,10 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|ValidationRule|RuleContract>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
{ {
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
], ],
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),

View File

@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
'still_active_email_sent_at' => 'datetime', 'still_active_email_sent_at' => 'datetime',
]; ];
public const array SELECT_COLUMNS = [
'id',
'description',
'start',
'end',
'billable_rate',
'billable',
'user_id',
'organization_id',
'project_id',
'task_id',
'tags',
'created_at',
'updated_at',
'member_id',
'client_id',
'is_imported',
'still_active_email_sent_at',
];
/** /**
* The attributes that are computed. (f.e. for performance reasons) * The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time. * These attributes can be regenerated at any time.

View File

@@ -6,6 +6,7 @@ namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
*/ */
public ?Collection $taskIds = null; public ?Collection $taskIds = null;
public ?TimeEntryRoundingType $roundingType = null;
public ?int $roundingMinutes = null;
/** /**
* Get the caster class to use when casting from / to this cast target. * Get the caster class to use when casting from / to this cast target.
* *
@@ -115,6 +120,10 @@ class ReportPropertiesDto implements Castable
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart); $dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone; $dto->timezone = $data->timezone;
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
return $dto; return $dto;
} }
@@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value, 'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value, 'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone, 'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
]; ];
$jsonString = json_encode($data); $jsonString = json_encode($data);

View File

@@ -6,6 +6,7 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
* cost: int|null * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): 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;
$group1Select = null; $group1Select = null;
@@ -56,15 +57,14 @@ class TimeEntryAggregationService
} }
} }
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
$timeEntriesQuery->selectRaw( $timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round( ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
sum(
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
)
) as cost'
); );
if ($groupBy !== null) { if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy); $timeEntriesQuery->groupBy($groupBy);
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
* cost: int|null * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{ {
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate); $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
$keysGroup1 = []; $keysGroup1 = [];
$keysGroup2 = []; $keysGroup2 = [];

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryRoundingType;
use Illuminate\Support\Carbon;
use LogicException;
class TimeEntryService
{
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'start';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
}
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
}); });
} }
public function endWithDuration(Carbon $end, int $durationInSeconds): self
{
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
return [
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
'end' => $end->copy()->utc(),
];
});
}
public function start(Carbon $start): self public function start(Carbon $start): self
{ {
return $this->state(function (array $attributes) use ($start): array { return $this->state(function (array $attributes) use ($start): array {

View File

@@ -31,7 +31,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`); await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click(); await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click(); await page.getByText(projectName).click();
@@ -52,7 +52,7 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`); await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry with tag ${tagName}`);
// Add tag // Add tag
await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('button', { name: 'Tags' }).click();
@@ -74,7 +74,7 @@ async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status // Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click(); await page.getByRole('button', { name: 'Non-Billable' }).click();
@@ -103,7 +103,7 @@ test('test that project filtering works in reporting', async ({ page }) => {
// Go to reporting and filter by project1 // Go to reporting and filter by project1
await goToReporting(page); await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click(); await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(project1).click(); await page.getByRole('dialog').getByText(project1).click();
await Promise.all([ await Promise.all([
// escape // escape

2
package-lock.json generated
View File

@@ -16,7 +16,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -46,7 +46,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -160,30 +160,17 @@ body {
} }
/* Inter Variable Font with browser compatibility considerations */
@font-face { @font-face {
font-family: 'Outfit'; font-family: 'Inter';
src: url('/fonts/Outfit-Regular.ttf'); src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'),
font-weight: 400; url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
} url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
@font-face { url('/fonts/Inter-Variable.ttf') format('truetype-variations');
font-family: 'Outfit'; font-weight: 100 900;
src: url('/fonts/Outfit-Medium.ttf'); font-style: normal;
font-weight: 500; font-display: swap;
} font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-ExtraBold.ttf');
font-weight: 800;
} }
@layer base { @layer base {
@@ -205,7 +192,7 @@ body {
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--theme-color-input-background); --input: var(--color-border-tertiary);
--ring: var(--theme-color-ring); --ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400); --chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500); --chart-2: var(--color-accent-500);
@@ -232,7 +219,7 @@ body {
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--theme-color-input-background); --input: var(--color-border-tertiary);
--ring: var(--theme-color-ring); --ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200); --chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300); --chart-2: var(--color-accent-300);

View File

@@ -127,7 +127,7 @@ const option = computed(() => ({
fontWeight: 600, fontWeight: 600,
color: labelColor.value, color: labelColor.value,
margin: 16, margin: 16,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Inter, sans-serif',
}, },
axisTick: { axisTick: {
lineStyle: { lineStyle: {
@@ -139,7 +139,7 @@ const option = computed(() => ({
type: 'value', type: 'value',
axisLabel: { axisLabel: {
color: labelColor.value, color: labelColor.value,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Inter, sans-serif',
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import Badge from '@/packages/ui/src/Badge.vue'; import { Button } from '@/Components/ui/button';
const props = defineProps<{ const props = defineProps<{
icon: Component; icon: Component;
@@ -12,32 +12,39 @@ import { twMerge } from 'tailwind-merge';
const activeClass = computed(() => { const activeClass = computed(() => {
if (props.active) { if (props.active) {
return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20'; return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
} }
return ''; return '';
}); });
const iconClass = computed(() => {
return twMerge(
'-ml-0.5 h-4 w-4',
props.active ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-text-quaternary'
);
});
</script> </script>
<template> <template>
<Badge <Button
size="large" variant="outline"
tag="button" size="sm"
:class=" :class="
twMerge( twMerge(
'cursor-pointer bg-input-background hover:bg-card-background transition flex',
activeClass activeClass
) )
"> ">
<component <component
:is="icon" :is="icon"
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component> :class="iconClass"
></component>
<span class="text-nowrap"> {{ title }} </span> <span class="text-nowrap"> {{ title }} </span>
<div <div
v-if="count" v-if="count"
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center"> class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ count }} {{ count }}
</div> </div>
</Badge> </Button>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -16,6 +16,7 @@ import {
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue'; import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
@@ -33,7 +34,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue'; import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { import {
@@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
@@ -71,6 +75,9 @@ const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]); const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null); const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const group = useStorage<GroupingOption>('reporting-group', 'project'); const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task'); const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
@@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
// Watch rounding enabled state to trigger updates
watch(roundingEnabled, () => {
updateReporting();
});
function getFilterAttributes(): AggregatedTimeEntriesQueryParams { function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = { let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(), start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
@@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
getCurrentRole() === 'employee' getCurrentRole() === 'employee'
? getCurrentMembershipId() ? getCurrentMembershipId()
: undefined, : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
}; };
return params; return params;
} }
@@ -305,7 +319,7 @@ const tableData = computed(() => {
<div class="py-2.5 w-full border-b border-default-background-separator"> <div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> <MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div <div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4"> class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
<div class="text-sm font-medium">Filters</div> <div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown <MemberMultiselectDropdown
v-model="selectedMembers" v-model="selectedMembers"
@@ -395,6 +409,11 @@ const tableData = computed(() => {
:icon="BillableIcon"></ReportingFilterBadge> :icon="BillableIcon"></ReportingFilterBadge>
</template> </template>
</SelectDropdown> </SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateReporting"></ReportingRoundingControls>
</div> </div>
<div> <div>
<DateRangePicker <DateRangePicker
@@ -490,7 +509,7 @@ const tableData = computed(() => {
<div <div
v-else v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3"> class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-semibold"> <p class="text-lg text-text-primary font-medium">
No time entries found No time entries found
</p> </p>
<p>Try to change the filters and time range</p> <p>Try to change the filters and time range</p>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/Components/ui/select';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import {
NumberField,
NumberFieldInput,
NumberFieldContent,
NumberFieldIncrement,
NumberFieldDecrement
} from '@/Components/ui/number-field';
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
// TimeEntryRoundingType definition
const TimeEntryRoundingType = {
Up: 'up' as const,
Down: 'down' as const,
Nearest: 'nearest' as const,
} as const;
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType];
interface Props {
enabled: boolean;
type: TimeEntryRoundingType;
minutes: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:enabled': [value: boolean];
'update:type': [value: TimeEntryRoundingType];
'update:minutes': [value: number];
'change': [];
}>();
function updateEnabled(value: boolean) {
emit('update:enabled', value);
emit('change');
}
function updateType(value: TimeEntryRoundingType) {
emit('update:type', value);
emit('change');
}
function updateMinutes(value: number) {
emit('update:minutes', value);
emit('change');
}
// Predefined intervals
const predefinedIntervals = [
{ value: '5', label: '5 minutes' },
{ value: '6', label: '6 minutes' },
{ value: '10', label: '10 minutes' },
{ value: '15', label: '15 minutes' },
{ value: '30', label: '30 minutes' },
{ value: '60', label: '1 hour' },
{ value: 'custom', label: 'Custom' },
];
const showCustomInput = ref(false);
const customMinutes = ref(props.minutes);
const selectedInterval = ref('');
// Compute the current interval value based on props
const currentInterval = computed(() => {
const predefined = predefinedIntervals.find(interval =>
interval.value !== 'custom' && parseInt(interval.value) === props.minutes
);
return predefined ? predefined.value : 'custom';
});
// Initialize selectedInterval
const initializeSelectedInterval = () => {
selectedInterval.value = currentInterval.value;
showCustomInput.value = selectedInterval.value === 'custom';
if (showCustomInput.value) {
customMinutes.value = props.minutes;
}
};
function handleIntervalChange(value: string) {
selectedInterval.value = value;
if (value === 'custom') {
showCustomInput.value = true;
// Update minutes to current custom value to ensure "custom" shows as selected
updateMinutes(customMinutes.value);
} else {
showCustomInput.value = false;
const minutes = parseInt(value);
updateMinutes(minutes);
}
}
function handleCustomMinutesChange(value: string | number) {
const numValue = typeof value === 'string' ? parseInt(value) : value;
if (!isNaN(numValue) && numValue > 0) {
customMinutes.value = numValue;
updateMinutes(numValue);
}
}
// Watch for changes in props.minutes
watch(() => props.minutes, (newMinutes) => {
customMinutes.value = newMinutes;
initializeSelectedInterval();
}, { immediate: true });
watch(currentInterval, () => {
initializeSelectedInterval();
});
// Active styling similar to ReportingFilterBadge
const activeClass = computed(() => {
if (props.enabled) {
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
}
return '';
});
const iconClass = computed(() => {
return twMerge(
'w-4 h-4',
props.enabled ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-muted-foreground opacity-50'
);
});
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button
variant="outline"
size="sm"
:class="twMerge(activeClass)">
<ArrowsUpDownIcon :class="iconClass" />
Rounding {{ enabled ? 'on' : 'off' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-4">
<div class="space-y-4">
<div>
<div class="flex items-center justify-between">
<InputLabel for="enable-rounding" value="Enable Rounding" />
<Switch
id="enable-rounding"
:model-value="enabled"
class="data-[state=checked]:bg-accent-500"
@update:model-value="updateEnabled" />
</div>
<div class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary">
Rounding is applied to each individual time entry, not to the accumulated total.
</div>
</div>
<div>
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
<Select
:model-value="type"
:disabled="!enabled"
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled">
<SelectValue placeholder="Select rounding type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="up">Round Up</SelectItem>
<SelectItem value="down">Round Down</SelectItem>
<SelectItem value="nearest">Round Nearest</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
<Select
:model-value="selectedInterval"
:disabled="!enabled"
@update:model-value="(value) => handleIntervalChange(value as string)">
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interval in predefinedIntervals"
:key="interval.value"
:value="interval.value">
{{ interval.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="showCustomInput" class="mt-2">
<NumberField
id="custom-minutes"
:model-value="customMinutes"
size="small"
:min="1"
:max="1440"
:disabled="!enabled"
class="text-sm"
@update:model-value="handleCustomMinutesChange">
<NumberFieldContent>
<NumberFieldDecrement :disabled="!enabled" />
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" />
<NumberFieldIncrement :disabled="!enabled" />
</NumberFieldContent>
</NumberField>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -43,7 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
</div> </div>
<div> <div>
<div class="text-text-secondary font-extrabold text-xs"> <div class="text-text-secondary font-medium text-xs">
Current Timer Current Timer
</div> </div>
<div class="text-text-primary font-medium text-lg"> <div class="text-text-primary font-medium text-lg">

View File

@@ -49,7 +49,7 @@ const option = computed(() => ({
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
margin: 24, margin: 24,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Inter, sans-serif',
}, },
axisTick: { axisTick: {
lineStyle: { lineStyle: {

View File

@@ -18,9 +18,9 @@ defineProps<{
<template> <template>
<div <div
class="px-3.5 py-2 flex justify-between @container border-b border-card-background-separator"> class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator">
<div class="flex items-center min-w-[70px]"> <div class="flex items-center min-w-[70px]">
<p class="font-semibold text-sm text-text-primary"> <p class="font-medium text-sm text-text-primary">
{{ formatHumanReadableDate(date) }} {{ formatHumanReadableDate(date) }}
</p> </p>
</div> </div>
@@ -28,7 +28,7 @@ defineProps<{
<DayOverviewCardChart :history="history"></DayOverviewCardChart> <DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div> </div>
<div <div
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold"> class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
duration, duration,

View File

@@ -47,7 +47,7 @@ async function startTaskTimer() {
<template> <template>
<div <div
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator"> class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator">
<div class="col-span-4"> <div class="col-span-4">
<p class="font-medium text-text-primary text-sm pb-1 truncate"> <p class="font-medium text-text-primary text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span> <span v-if="timeEntry.description"> {{ timeEntry.description }}</span>

View File

@@ -7,7 +7,7 @@ defineProps<{
</script> </script>
<template> <template>
<div class="px-4 py-2 2xl:py-3 border-b border-card-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 class="font-semibold text-sm text-text-primary"> <p class="font-semibold text-sm text-text-primary">

View File

@@ -202,7 +202,7 @@ const option = computed(() => {
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
margin: 24, margin: 24,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Inter, sans-serif',
color: labelColor.value, color: labelColor.value,
}, },
axisTick: { axisTick: {
@@ -215,7 +215,7 @@ const option = computed(() => {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
color: labelColor.value, color: labelColor.value,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Inter, sans-serif',
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {

View File

@@ -32,7 +32,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open" <CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5"> ><CollapsibleTrigger class="w-full group py-0.5">
<div <div
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between"> class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<component <component
:is="icon" :is="icon"
@@ -41,7 +41,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
current current
? 'text-icon-active' ? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active', : 'text-icon-default group-hover:text-icon-active',
'transition h-5 w-5 shrink-0', 'transition h-4 w-4 shrink-0',
]" ]"
aria-hidden="true" /> aria-hidden="true" />
<span> <span>

View File

@@ -16,7 +16,7 @@ defineProps<{
current current
? 'bg-menu-active text-text-primary' ? 'bg-menu-active text-text-primary'
: 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ', : 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ',
'group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center', 'group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center',
]"> ]">
<component <component
:is="icon" :is="icon"
@@ -25,7 +25,7 @@ defineProps<{
current current
? 'text-icon-active' ? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active', : 'text-icon-default group-hover:text-icon-active',
'transition h-5 w-5 shrink-0', 'transition h-4 w-4 shrink-0',
]" ]"
aria-hidden="true" /> aria-hidden="true" />
{{ title }} {{ title }}

View File

@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue' export { default as Button } from './Button.vue'
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
destructive: destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

@@ -14,7 +14,7 @@ const delegatedProps = computed(() => {
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
const sizeClasses = computed(() => { const sizeClasses = computed(() => {
return props.size === 'small' ? 'h-[34px]' : 'h-[42px]' return props.size === 'small' ? 'h-[34px] text-sm' : 'h-[42px]'
}) })
</script> </script>

View File

@@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SwitchRoot <SwitchRoot
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-white bg-white/50', 'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class, props.class,
)" )"
> >
<SwitchThumb <SwitchThumb
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0')" :class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4')"
> >
<slot name="thumb" /> <slot name="thumb" />
</SwitchThumb> </SwitchThumb>

View File

@@ -170,7 +170,7 @@ const page = usePage<{
</nav> </nav>
<div <div
class="text-text-tertiary text-sm font-semibold pt-5 pb-1.5"> class="text-text-tertiary text-xs font-semibold pt-5 pb-1.5">
Manage Manage
</div> </div>
@@ -218,7 +218,7 @@ const page = usePage<{
</nav> </nav>
<div <div
v-if="canUpdateOrganization()" v-if="canUpdateOrganization()"
class="text-text-tertiary text-sm font-semibold pt-5 pb-1.5"> class="text-text-tertiary text-xs font-semibold pt-5 pb-1.5">
Admin Admin
</div> </div>

View File

@@ -16,6 +16,7 @@ import {
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { import {
getDayJsInstance, getDayJsInstance,
@@ -69,6 +70,9 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions'; import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
@@ -83,9 +87,17 @@ const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]); const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]); const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null); const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const { members } = storeToRefs(useMembersStore()); const { members } = storeToRefs(useMembersStore());
const pageLimit = 15; const pageLimit = 15;
// Watch rounding enabled state to trigger updates
watch(roundingEnabled, () => {
updateFilteredTimeEntries();
});
const currentPage = ref(1); const currentPage = ref(1);
function getFilterAttributes() { function getFilterAttributes() {
@@ -115,6 +127,8 @@ function getFilterAttributes() {
: undefined, : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined, billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
}; };
return params; return params;
} }
@@ -268,7 +282,7 @@ async function downloadExport(format: ExportFormat) {
<MainContainer <MainContainer
class="sm:flex space-y-4 sm:space-y-0 justify-between"> class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div <div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4"> class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
<div class="text-sm font-medium">Filters</div> <div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown <MemberMultiselectDropdown
v-model="selectedMembers" v-model="selectedMembers"
@@ -358,6 +372,11 @@ async function downloadExport(format: ExportFormat) {
:icon="BillableIcon"></ReportingFilterBadge> :icon="BillableIcon"></ReportingFilterBadge>
</template> </template>
</SelectDropdown> </SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateFilteredTimeEntries" />
</div> </div>
<div> <div>
<DateRangePicker <DateRangePicker

View File

@@ -123,7 +123,12 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias< export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
SolidTimeApi, SolidTimeApi,
'getAggregatedTimeEntries' 'getAggregatedTimeEntries'
> & { start: string; end: string }; > & {
start: string;
end: string;
rounding_type?: string;
rounding_minutes?: number;
};
export type OrganizationResponse = ZodiosResponseByAlias< export type OrganizationResponse = ZodiosResponseByAlias<
SolidTimeApi, SolidTimeApi,

View File

@@ -47,7 +47,7 @@ const tagClasses = computed(() => {
tagClasses, tagClasses,
badgeClasses[size], badgeClasses[size],
borderClasses, borderClasses,
'rounded transition inline-flex items-center font-semibold text-text-primary disabled:text-text-quaternary outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'rounded transition inline-flex items-center font-medium text-text-primary disabled:text-text-quaternary outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
props.class props.class
) )
"> ">

View File

@@ -8,13 +8,13 @@ defineProps<{
</script> </script>
<template> <template>
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-4"> <div class="flex w-full items-center justify-between pb-2.5 lg:pb-3">
<h3 <h3
class="text-text-primary font-semibold text-sm lg:text-base flex items-center space-x-2 lg:space-x-2.5"> class="text-text-primary font-medium text-sm lg:text-base flex items-center space-x-1.5 lg:space-x-2">
<component <component
:is="icon" :is="icon"
v-if="icon" v-if="icon"
class="w-5 lg:w-6 text-icon-default"></component> class="w-4 lg:w-4 text-icon-default"></component>
<span> <span>
{{ title }} {{ title }}
</span> </span>

View File

@@ -4,6 +4,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/Components/ui/popover'; } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { RangeCalendar } from '@/Components/ui/range-calendar'; import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date'; import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next'; import { CalendarIcon } from 'lucide-vue-next';
@@ -208,14 +209,15 @@ watch(open, (value) => {
<template> <template>
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<button <Button
variant="outline"
:class=" :class="
twMerge( twMerge(
'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 h-[34px] shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start', 'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
!modelValue && 'text-muted-foreground' !modelValue && 'text-muted-foreground'
) )
"> ">
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<template v-if="modelValue.start"> <template v-if="modelValue.start">
<template v-if="modelValue.end"> <template v-if="modelValue.end">
{{ {{
@@ -242,23 +244,23 @@ watch(open, (value) => {
</template> </template>
</template> </template>
<template v-else> Pick a date </template> <template v-else> Pick a date </template>
</button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0"> <PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary"> <div class="flex divide-x divide-border-secondary">
<div <div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1"> class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<button @click="setToday">Today</button> <Button variant="ghost" size="sm" class="justify-start" @click="setToday">Today</Button>
<button @click="setThisWeek">This Week</button> <Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek">This Week</Button>
<button @click="setLastWeek">Last Week</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek">Last Week</Button>
<button @click="setLast14Days">Last 14 days</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days">Last 14 days</Button>
<button @click="setThisMonth">This Month</button> <Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth">This Month</Button>
<button @click="setLastMonth">Last Month</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth">Last Month</Button>
<button @click="setLast30Days">Last 30 days</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days">Last 30 days</Button>
<button @click="setLast90Days">Last 90 days</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days">Last 90 days</Button>
<button @click="setLast12Months">Last 12 months</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months">Last 12 months</Button>
<button @click="setThisYear">This year</button> <Button variant="ghost" size="sm" class="justify-start" @click="setThisYear">This year</Button>
<button @click="setLastYear">Last year</button> <Button variant="ghost" size="sm" class="justify-start" @click="setLastYear">Last year</Button>
</div> </div>
<div class="pl-2"> <div class="pl-2">
<RangeCalendar <RangeCalendar

View File

@@ -136,6 +136,7 @@ type BillableOption = {
id="description" id="description"
ref="description" ref="description"
v-model="timeEntry.description" v-model="timeEntry.description"
aria-label="Description"
placeholder="What did you work on?" placeholder="What did you work on?"
type="text" type="text"
class="mt-1 block w-full" class="mt-1 block w-full"

View File

@@ -43,7 +43,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
showDate showDate
? 'text-xs py-1.5 font-semibold' ? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium', : 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
open && 'border-card-border bg-card-background' open && 'border-card-border bg-card-background'
) )
"> ">

View File

@@ -38,7 +38,7 @@ function selectUnselectAll(value: boolean) {
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-5"> <div class="w-5">
<svg <svg
class="w-4 sm:w-5 text-icon-default group-hover:hidden block" class="w-3 sm:w-4 text-icon-default group-hover:hidden block"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<g fill="none"> <g fill="none">
@@ -54,15 +54,15 @@ function selectUnselectAll(value: boolean) {
class="group-hover:block hidden" class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox> @update:checked="selectUnselectAll"></Checkbox>
</div> </div>
<span class="font-semibold text-text-primary"> <span class="font-medium text-text-primary">
{{ formatWeekday(date) }} {{ formatWeekday(date) }}
</span> </span>
<span class="font-semibold text-text-secondary"> <span class="font-medium text-text-secondary">
{{ formatDate(date, organization?.date_format) }} {{ formatDate(date, organization?.date_format) }}
</span> </span>
</div> </div>
<div class="text-text-secondary pr-[90px] lg:pr-[92px]"> <div class="text-text-secondary pr-[90px] lg:pr-[92px]">
<span class="font-semibold"> <span class="font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
duration, duration,

View File

@@ -211,7 +211,7 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
v-model="tempDescription" v-model="tempDescription"
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text" type="text"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"

View File

@@ -1,14 +1,14 @@
<x-filament-widgets::widget> <x-filament-widgets::widget>
<x-filament::section> <x-filament::section>
<div> <div>
<span class="text-gray-950 font-bold">Version</span> <span class="text-gray-950 font-bold dark:text-white">Version</span>
@if($version !== null) @if($version !== null)
<span>v{{ $version }}</span> <span>v{{ $version }}</span>
@else @else
<span>-</span> <span>-</span>
@endif @endif
<br> <br>
<span class="text-gray-950 font-bold">Build</span> <span class="text-gray-950 font-bold dark:text-white">Build</span>
@if($build !== null) @if($build !== null)
<span>{{ $build }}</span> <span>{{ $build }}</span>
@else @else

View File

@@ -4,14 +4,15 @@ import typography from "@tailwindcss/typography";
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
darkMode: ["selector", "class"], darkMode: ["selector", ".dark"],
content: [ content: [
"./extensions/Invoicing/resources/js/**/*.vue", "./extensions/Invoicing/resources/js/**/*.vue",
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php", "./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
"./vendor/laravel/jetstream/**/*.blade.php", "./vendor/laravel/jetstream/**/*.blade.php",
"./storage/framework/views/*.php", "./storage/framework/views/*.php",
"./resources/views/**/*.blade.php", "./resources/views/**/*.blade.php",
"./resources/js/**/*.vue" "./resources/js/**/*.vue",
"./resources/js/**/*.ts"
], ],
theme: { theme: {
extend: { extend: {
@@ -24,10 +25,25 @@ export default {
}, },
fontFamily: { fontFamily: {
sans: [ sans: [
"Outfit", "Inter",
...defaultTheme.fontFamily.sans ...defaultTheme.fontFamily.sans
] ]
}, },
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }]
},
colors: { colors: {
ring: "var(--ring)", ring: "var(--ring)",
primary: { primary: {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1; namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Controllers\Api\V1\ReportController; use App\Http\Controllers\Api\V1\ReportController;
use App\Models\Report; use App\Models\Report;
@@ -162,6 +163,61 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
); );
} }
public function test_store_endpoint_creates_new_report_with_rounding_properties(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report with Rounding',
'description' => 'Test description',
'is_public' => true,
'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'active' => true,
'member_ids' => [],
'billable' => true,
'client_ids' => [],
'project_ids' => [],
'tag_ids' => [],
'task_ids' => [],
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'week_start' => Weekday::Monday->value,
'timezone' => 'Europe/Berlin',
'rounding_type' => 'nearest',
'rounding_minutes' => 15,
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', 'Test Report with Rounding')
->where('data.description', 'Test description')
->where('data.is_public', true)
->where('data.shareable_link', $report->getShareableLink())
->where('data.properties.group', TimeEntryAggregationType::Project->value)
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
->where('data.properties.rounding_type', 'nearest')
->where('data.properties.rounding_minutes', 15)
);
// Also verify the properties are saved in the database
$this->assertSame(TimeEntryRoundingType::Nearest, $report->properties->roundingType);
$this->assertSame(15, $report->properties->roundingMinutes);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
{ {
// Arrange // Arrange

View File

@@ -8,6 +8,7 @@ use App\Enums\ExportFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Http\Controllers\Api\V1\TimeEntryController; use App\Http\Controllers\Api\V1\TimeEntryController;
use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForProject;
@@ -389,6 +390,141 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
); );
} }
public function test_index_endpoint_can_round_up(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_can_round_down(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Down,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:00:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:12:00Z')
);
}
public function test_index_endpoint_can_round_nearest(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:00'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:02:59'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Nearest,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:00:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void
{ {
// Arrange // Arrange

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
@@ -40,7 +41,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
null, null,
null, null,
true true,
null,
null
); );
// Assert // Assert
@@ -87,7 +90,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
Carbon::now()->subDays(2)->utc(), Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(), Carbon::now()->subDay()->utc(),
true true,
null,
null
); );
// Assert // Assert
@@ -172,7 +177,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
Carbon::now()->subDays(2)->utc(), Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(), Carbon::now()->subDay()->utc(),
false false,
null,
null
); );
// Assert // Assert
@@ -238,7 +245,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2)->utc(), Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(), Carbon::now()->subDay()->utc(),
true true,
null,
null
); );
// Assert // Assert
@@ -280,7 +289,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2), Carbon::now()->subDays(2),
Carbon::now()->subDay(), Carbon::now()->subDay(),
true true,
null,
null
); );
// Assert // Assert
@@ -307,7 +318,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2), Carbon::now()->subDays(2),
Carbon::now()->subDay(), Carbon::now()->subDay(),
true true,
null,
null
); );
// Assert // Assert
@@ -343,7 +356,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
null, null,
null, null,
true true,
null,
null
); );
// Assert // Assert
@@ -408,6 +423,302 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], $result); ], $result);
} }
public function test_aggregate_time_can_round_up_per_time_entry(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
->forProject($project1)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
->forProject($project1)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)
->forProject($project2)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
->forProject($project3)
->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 4500,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 1800,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client1->getKey(),
'seconds' => 1800,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 1800,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client2->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
public function test_aggregate_time_can_round_down_per_time_entry(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)
->forProject($project1)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)
->forProject($project1)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)
->forProject($project2)->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 450)
->forProject($project3)
->create();
TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 449)
->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Down,
15
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 1800,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 1800,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client1->getKey(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client2->getKey(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
public function test_aggregate_time_can_round_to_nearest_per_time_entry(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 449)
->forProject($project1)->create();
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
->forProject($project1)->create();
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
->forProject($project2)->create();
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
->forProject($project3)
->create();
TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)
->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Nearest,
15
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 3600,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 1800,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client1->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client2->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 900,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
// TODO: test with 1 minute
public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void
{ {
// Arrange // Arrange
@@ -432,7 +743,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
null, null,
null, null,
true true,
null,
null
); );
// Assert // Assert
@@ -528,7 +841,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
null, null,
null, null,
true true,
null,
null,
); );
// Assert // Assert
@@ -612,7 +927,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
null, null,
null, null,
true true,
null,
null,
); );
// Assert // Assert