mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-20 22:22:16 +01:00
Format date group labels in all aggregate exports
This commit is contained in:
@@ -535,7 +535,7 @@ class TimeEntryController extends Controller
|
|||||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||||
} else {
|
} else {
|
||||||
Excel::store(
|
Excel::store(
|
||||||
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
|
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
|
||||||
$path,
|
$path,
|
||||||
config('filesystems.private'),
|
config('filesystems.private'),
|
||||||
$format->getExportPackageType(),
|
$format->getExportPackageType(),
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat;
|
|||||||
use App\Enums\DateFormat;
|
use App\Enums\DateFormat;
|
||||||
use App\Enums\IntervalFormat;
|
use App\Enums\IntervalFormat;
|
||||||
use App\Enums\NumberFormat;
|
use App\Enums\NumberFormat;
|
||||||
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use Brick\Math\BigDecimal;
|
use Brick\Math\BigDecimal;
|
||||||
use Brick\Money\Money;
|
use Brick\Money\Money;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class LocalizationService
|
class LocalizationService
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,29 @@ class LocalizationService
|
|||||||
return $date->format($this->dateFormat->toCarbonFormat());
|
return $date->format($this->dateFormat->toCarbonFormat());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time group types have no server-side descriptor, so reports fall back to rendering the raw
|
||||||
|
* aggregation key. For the Day type that key is an ISO date (Y-m-d), which is formatted here
|
||||||
|
* so exports match the rest of the UI instead of leaking the key.
|
||||||
|
*
|
||||||
|
* Week, month and year keys have different shapes ('Y-m-d' for the week start, 'Y-m' and 'Y'),
|
||||||
|
* and are not offered as a grouping in the reporting UI, so they are returned unchanged.
|
||||||
|
* Presenting those needs its own decision: a week rendered as its start date reads as a single
|
||||||
|
* day, and a month rendered with a full date format reads as a specific day.
|
||||||
|
*/
|
||||||
|
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupType !== TimeEntryAggregationType::Day) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->formatDate(Carbon::parse($key));
|
||||||
|
}
|
||||||
|
|
||||||
public function setDateFormat(DateFormat $dateFormat): void
|
public function setDateFormat(DateFormat $dateFormat): void
|
||||||
{
|
{
|
||||||
$this->dateFormat = $dateFormat;
|
$this->dateFormat = $dateFormat;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Service\ReportExport;
|
|||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Service\LocalizationService;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Maatwebsite\Excel\Concerns\Exportable;
|
use Maatwebsite\Excel\Concerns\Exportable;
|
||||||
use Maatwebsite\Excel\Concerns\FromView;
|
use Maatwebsite\Excel\Concerns\FromView;
|
||||||
@@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
|
|
||||||
private bool $showBillableRate;
|
private bool $showBillableRate;
|
||||||
|
|
||||||
|
private LocalizationService $localization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
@@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
|
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
|
||||||
{
|
{
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->exportFormat = $exportFormat;
|
$this->exportFormat = $exportFormat;
|
||||||
@@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
$this->group = $group;
|
$this->group = $group;
|
||||||
$this->subGroup = $subGroup;
|
$this->subGroup = $subGroup;
|
||||||
$this->showBillableRate = $showBillableRate;
|
$this->showBillableRate = $showBillableRate;
|
||||||
|
$this->localization = $localization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(): View
|
public function view(): View
|
||||||
@@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
'subGroup' => $this->subGroup,
|
'subGroup' => $this->subGroup,
|
||||||
'exportFormat' => $this->exportFormat,
|
'exportFormat' => $this->exportFormat,
|
||||||
'showBillableRate' => $this->showBillableRate,
|
'showBillableRate' => $this->showBillableRate,
|
||||||
|
'localization' => $this->localization,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
|
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
|
||||||
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
|
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
|
||||||
@else
|
@else
|
||||||
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
|
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
<span style="color: #a1a1aa;">
|
<span style="color: #a1a1aa;">
|
||||||
{{ $group->description() }}:
|
{{ $group->description() }}:
|
||||||
</span>
|
</span>
|
||||||
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
|
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
|
||||||
@endif
|
@endif
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
|
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
|
||||||
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
|
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
|
||||||
@else
|
@else
|
||||||
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
|
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -318,7 +318,7 @@
|
|||||||
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
|
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group, $localization): object {
|
||||||
$color = $data['color'];
|
$color = $data['color'];
|
||||||
if ($color === null) {
|
if ($color === null) {
|
||||||
$color = $colorService->getRandomColor($data['key']);
|
$color = $colorService->getRandomColor($data['key']);
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
return (object)[
|
return (object)[
|
||||||
'value' => $data['seconds'],
|
'value' => $data['seconds'],
|
||||||
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
|
'name' => $data['description'] ?? $localization->formatTimeGroupKey($data['key'], $group) ?? 'No '.Str::lower($group->description()),
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
'itemStyle' => (object) [
|
'itemStyle' => (object) [
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</td>
|
</td>
|
||||||
@else
|
@else
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
||||||
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
|
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
@if ($subGroup === TimeEntryAggregationType::Billable)
|
@if ($subGroup === TimeEntryAggregationType::Billable)
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</td>
|
</td>
|
||||||
@else
|
@else
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
||||||
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
|
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</td>
|
</td>
|
||||||
@else
|
@else
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
||||||
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
|
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
@if ($subGroup === TimeEntryAggregationType::Billable)
|
@if ($subGroup === TimeEntryAggregationType::Billable)
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</td>
|
</td>
|
||||||
@else
|
@else
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
|
||||||
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
|
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
|
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\DateFormat;
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\TagMatchType;
|
use App\Enums\TagMatchType;
|
||||||
@@ -1641,6 +1642,106 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_date(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||||
|
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
'group' => TimeEntryAggregationType::Day,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Day,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_pdf_renders_date_group_labels_in_organization_date_format(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
// Note: the organization factory randomizes the date format, so pin it
|
||||||
|
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
|
||||||
|
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::PDF,
|
||||||
|
'group' => TimeEntryAggregationType::Day,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Day,
|
||||||
|
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'debug' => 'true',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$html = $response->json('html');
|
||||||
|
$this->assertIsString($html);
|
||||||
|
$this->assertStringContainsString('14/03/2024', $html);
|
||||||
|
$this->assertStringNotContainsString('2024-03-14', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_csv_renders_date_group_labels_in_organization_date_format(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
// Note: the organization factory randomizes the date format, so pin it
|
||||||
|
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
|
||||||
|
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Day,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Day,
|
||||||
|
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$disk = Storage::disk(config('filesystems.private'));
|
||||||
|
$files = $disk->files('exports');
|
||||||
|
$this->assertCount(1, $files);
|
||||||
|
$csv = $disk->get($files[0]);
|
||||||
|
$this->assertIsString($csv);
|
||||||
|
$this->assertStringContainsString('14/03/2024', $csv);
|
||||||
|
$this->assertStringNotContainsString('2024-03-14', $csv);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
|
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\CurrencyFormat;
|
|||||||
use App\Enums\DateFormat;
|
use App\Enums\DateFormat;
|
||||||
use App\Enums\IntervalFormat;
|
use App\Enums\IntervalFormat;
|
||||||
use App\Enums\NumberFormat;
|
use App\Enums\NumberFormat;
|
||||||
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Service\LocalizationService;
|
use App\Service\LocalizationService;
|
||||||
use Brick\Money\Currency;
|
use Brick\Money\Currency;
|
||||||
@@ -303,4 +304,39 @@ class LocalizationServiceTest extends TestCaseWithDatabase
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertSame('14:09', $formatted);
|
$this->assertSame('14:09', $formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_format_time_group_key_formats_a_day_key_with_the_date_format(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Day);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('03/02/2001', $formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_format_time_group_key_returns_null_for_a_null_key(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatTimeGroupKey(null, TimeEntryAggregationType::Day);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNull($formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_format_time_group_key_returns_the_key_unchanged_for_non_day_group_types(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
// Week/month/year are not offered as a grouping in the reporting UI and their keys have
|
||||||
|
// different shapes, so they are passed through rather than formatted as a date.
|
||||||
|
$this->assertSame('2001-02-03', $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Week));
|
||||||
|
$this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
|
||||||
|
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
|
||||||
|
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user