mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
4 Commits
v0.11.5
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb41d60a21 | ||
|
|
6a740015b7 | ||
|
|
be873c72fe | ||
|
|
bbfa411f32 |
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal 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';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -140,8 +142,15 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
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()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->select($select)
|
||||
->orderBy('start', 'desc');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
@@ -183,6 +192,8 @@ class TimeEntryController extends Controller
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$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->with([
|
||||
@@ -207,8 +218,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 +228,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes,
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -324,6 +338,8 @@ class TimeEntryController extends Controller
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
@@ -334,7 +350,9 @@ class TimeEntryController extends Controller
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -373,6 +391,8 @@ class TimeEntryController extends Controller
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -383,7 +403,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -394,7 +416,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 +501,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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
42
app/Service/TimeEntryService.php
Normal file
42
app/Service/TimeEntryService.php
Normal 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).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
2052
public/fonts/Inter-Variable.ttf
Normal file
2052
public/fonts/Inter-Variable.ttf
Normal file
File diff suppressed because one or more lines are too long
2052
public/fonts/Inter-Variable.woff2
Normal file
2052
public/fonts/Inter-Variable.woff2
Normal file
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.
@@ -160,30 +160,17 @@ 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 supports variations'),
|
||||
url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -205,7 +192,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 +219,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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -49,7 +49,7 @@ const option = computed(() => ({
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: 24,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user