Compare commits

...

10 Commits

Author SHA1 Message Date
Gregor Vostrak
d6b45d3e35 fix font embeds #864 2025-07-26 16:49:26 +02:00
Constantin Graf
b11672732b Fixed modules service providers 2025-07-23 16:11:34 +02:00
Gregor Vostrak
97dcadc795 add frontend blocking for rounding for non-premium users 2025-07-23 16:09:36 +02:00
Constantin Graf
e7fa414c06 Restrict rounding to premium users 2025-07-23 16:09:36 +02:00
Gregor Vostrak
43073b5be2 fix design inconsistency in timeentryaggregaterow 2025-07-18 16:38:09 +02:00
Gregor Vostrak
9589c9106d e2e: make sure reporting tests do not check the dropdown values when verifying table results 2025-07-17 18:41:48 +02:00
Gregor Vostrak
8a0d2235a8 fix flakyness in e2e tests for reporting 2025-07-17 18:38:21 +02:00
Gregor Vostrak
38f38790d5 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:38:21 +02:00
Gregor Vostrak
e3cfc155b8 add rounding frontend to reports, and support for shared reports 2025-07-17 18:38:21 +02:00
Constantin Graf
4b726635b2 Add rounding feature 2025-07-17 18:38:21 +02:00
61 changed files with 1289 additions and 136 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,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -84,7 +86,9 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

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

View File

@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
@@ -84,7 +86,8 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -138,10 +141,19 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
{
$select = TimeEntry::SELECT_COLUMNS;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
@@ -175,16 +187,19 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -207,8 +222,9 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
$timeEntriesAggregateQuery,
null,
null,
$user->timezone,
@@ -216,7 +232,9 @@ class TimeEntryController extends Controller
false,
null,
null,
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes,
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -318,12 +336,15 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -334,7 +355,9 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
return [
@@ -362,6 +385,7 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -373,6 +397,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
@@ -383,7 +409,9 @@ class TimeEntryController extends Controller
false,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -394,7 +422,9 @@ class TimeEntryController extends Controller
true,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -477,7 +507,7 @@ class TimeEntryController extends Controller
/**
* @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()
->whereBelongsTo($organization, 'organization');

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
'nullable',
'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'));
}
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\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'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'));
}
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;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'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;
}
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;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'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'));
}
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;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -11,8 +12,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* 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
{
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'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;
}
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(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'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 */
'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',
];
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)
* 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\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
*/
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.
*
@@ -115,6 +120,10 @@ class ReportPropertiesDto implements Castable
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$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;
}
@@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
];
$jsonString = json_encode($data);

View File

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

@@ -118,7 +118,8 @@
"extra": {
"laravel": {
"dont-discover": [
"laravel/telescope"
"laravel/telescope",
"nwidart/laravel-modules"
]
}
},

View File

@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
return [
@@ -197,6 +198,7 @@ return [
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),
/*

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
{
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();
// 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.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();
// 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
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();
// 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
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
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(project1).click();
await page.getByRole('dialog').getByText(project1).click();
await Promise.all([
// escape
@@ -114,8 +114,8 @@ test('test that project filtering works in reporting', async ({ page }) => {
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
@@ -142,7 +142,7 @@ test('test that tag filtering works in reporting', async ({ page }) => {
]);
// Verify only time entries with tag1 are shown
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
@@ -164,7 +164,7 @@ test('test that billable status filtering works in reporting', async ({ page })
]);
await page.waitForLoadState('networkidle');
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
});

2
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Binary file not shown.

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.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 ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.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 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 { storeToRefs } from 'pinia';
import {
@@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
@@ -71,6 +75,9 @@ const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
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 subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
@@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization');
// Watch rounding enabled state to trigger updates
watch(roundingEnabled, () => {
updateReporting();
});
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
@@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
getCurrentRole() === 'employee'
? getCurrentMembershipId()
: undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
};
return params;
}
@@ -305,7 +319,7 @@ const tableData = computed(() => {
<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">
<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>
<MemberMultiselectDropdown
v-model="selectedMembers"
@@ -395,6 +409,11 @@ const tableData = computed(() => {
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateReporting"></ReportingRoundingControls>
</div>
<div>
<DateRangePicker
@@ -490,7 +509,7 @@ const tableData = computed(() => {
<div
v-else
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
</p>
<p>Try to change the filters and time range</p>

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,9 @@ defineProps<{
<template>
<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]">
<p class="font-semibold text-sm text-text-primary">
<p class="font-medium text-sm text-text-primary">
{{ formatHumanReadableDate(date) }}
</p>
</div>
@@ -28,7 +28,7 @@ defineProps<{
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
</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(
duration,

View File

@@ -47,7 +47,7 @@ async function startTaskTimer() {
<template>
<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">
<p class="font-medium text-text-primary text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>

View File

@@ -7,7 +7,7 @@ defineProps<{
</script>
<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="flex justify-between">
<p class="font-semibold text-sm text-text-primary">

View File

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

View File

@@ -32,7 +32,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5">
<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">
<component
:is="icon"
@@ -41,7 +41,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
current
? '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" />
<span>

View File

@@ -16,7 +16,7 @@ defineProps<{
current
? 'bg-menu-active text-text-primary'
: '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
:is="icon"
@@ -25,7 +25,7 @@ defineProps<{
current
? '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" />
{{ title }}

View File

@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue'
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: {
variant: {
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
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:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

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

View File

@@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SwitchRoot
v-bind="forwarded"
: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,
)"
>
<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" />
</SwitchThumb>

View File

@@ -170,7 +170,7 @@ const page = usePage<{
</nav>
<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
</div>
@@ -218,7 +218,7 @@ const page = usePage<{
</nav>
<div
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
</div>

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ const tagClasses = computed(() => {
tagClasses,
badgeClasses[size],
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
)
">

View File

@@ -8,13 +8,13 @@ defineProps<{
</script>
<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
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
:is="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>
{{ title }}
</span>

View File

@@ -4,6 +4,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
@@ -208,14 +209,15 @@ watch(open, (value) => {
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<button
<Button
variant="outline"
:class="
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'
)
">
<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.end">
{{
@@ -242,23 +244,23 @@ watch(open, (value) => {
</template>
</template>
<template v-else> Pick a date </template>
</button>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<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">
<button @click="setToday">Today</button>
<button @click="setThisWeek">This Week</button>
<button @click="setLastWeek">Last Week</button>
<button @click="setLast14Days">Last 14 days</button>
<button @click="setThisMonth">This Month</button>
<button @click="setLastMonth">Last Month</button>
<button @click="setLast30Days">Last 30 days</button>
<button @click="setLast90Days">Last 90 days</button>
<button @click="setLast12Months">Last 12 months</button>
<button @click="setThisYear">This year</button>
<button @click="setLastYear">Last year</button>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button variant="ghost" size="sm" class="justify-start" @click="setToday">Today</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek">This Week</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek">Last Week</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days">Last 14 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth">This Month</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth">Last Month</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days">Last 30 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days">Last 90 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months">Last 12 months</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear">This year</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear">Last year</Button>
</div>
<div class="pl-2">
<RangeCalendar

View File

@@ -154,7 +154,7 @@ function onSelectChange(checked: boolean) {
"></BillableToggleButton>
<div class="flex-1">
<button
:class="twMerge('text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
:class="twMerge('text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]')"
@click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
</button>

View File

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

View File

@@ -43,7 +43,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
showDate
? 'text-xs py-1.5 font-semibold'
: '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'
)
">

View File

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

View File

@@ -211,7 +211,7 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
v-model="tempDescription"
placeholder="What are you working on?"
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"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"

View File

@@ -1,14 +1,14 @@
<x-filament-widgets::widget>
<x-filament::section>
<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)
<span>v{{ $version }}</span>
@else
<span>-</span>
@endif
<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)
<span>{{ $build }}</span>
@else

View File

@@ -4,14 +4,15 @@ import typography from "@tailwindcss/typography";
/** @type {import("tailwindcss").Config} */
export default {
darkMode: ["selector", "class"],
darkMode: ["selector", ".dark"],
content: [
"./extensions/Invoicing/resources/js/**/*.vue",
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
"./vendor/laravel/jetstream/**/*.blade.php",
"./storage/framework/views/*.php",
"./resources/views/**/*.blade.php",
"./resources/js/**/*.vue"
"./resources/js/**/*.vue",
"./resources/js/**/*.ts"
],
theme: {
extend: {
@@ -24,10 +25,25 @@ export default {
},
fontFamily: {
sans: [
"Outfit",
"Inter",
...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: {
ring: "var(--ring)",
primary: {

View File

@@ -21,13 +21,17 @@ abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected bool $mockBillingContract = true;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
LogFake::bind();
Http::preventStrayRequests();
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
if ($this->mockBillingContract) {
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
}
// Note: The following line can be used to test timezone edge cases.
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Controllers\Api\V1\ReportController;
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
{
// Arrange

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
@@ -40,7 +41,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
null,
null,
true
true,
null,
null
);
// Assert
@@ -87,7 +90,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
true
true,
null,
null
);
// Assert
@@ -172,7 +177,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
false
false,
null,
null
);
// Assert
@@ -238,7 +245,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
true
true,
null,
null
);
// Assert
@@ -280,7 +289,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2),
Carbon::now()->subDay(),
true
true,
null,
null
);
// Assert
@@ -307,7 +318,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
Carbon::now()->subDays(2),
Carbon::now()->subDay(),
true
true,
null,
null
);
// Assert
@@ -343,7 +356,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
null,
null,
true
true,
null,
null
);
// Assert
@@ -408,6 +423,302 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], $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
{
// Arrange
@@ -432,7 +743,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true,
null,
null,
true
true,
null,
null
);
// Assert
@@ -528,7 +841,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
null,
null,
true
true,
null,
null,
);
// Assert
@@ -612,7 +927,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false,
null,
null,
true
true,
null,
null,
);
// Assert