diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php
index 75641fea..be6eb519 100644
--- a/app/Http/Controllers/Api/V1/TimeEntryController.php
+++ b/app/Http/Controllers/Api/V1/TimeEntryController.php
@@ -535,7 +535,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
- new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
+ new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
diff --git a/app/Service/LocalizationService.php b/app/Service/LocalizationService.php
index a03d1b7d..bee7490a 100644
--- a/app/Service/LocalizationService.php
+++ b/app/Service/LocalizationService.php
@@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
+use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
+use Illuminate\Support\Carbon;
class LocalizationService
{
@@ -152,6 +154,29 @@ class LocalizationService
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
{
$this->dateFormat = $dateFormat;
diff --git a/app/Service/ReportExport/TimeEntriesReportExport.php b/app/Service/ReportExport/TimeEntriesReportExport.php
index 5b6f3048..3ae6e5ea 100644
--- a/app/Service/ReportExport/TimeEntriesReportExport.php
+++ b/app/Service/ReportExport/TimeEntriesReportExport.php
@@ -6,6 +6,7 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
+use App\Service\LocalizationService;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
@@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate;
+ private LocalizationService $localization;
+
/**
* @param array{
* grouped_type: string|null,
@@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $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->exportFormat = $exportFormat;
@@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
+ $this->localization = $localization;
}
public function view(): View
@@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
+ 'localization' => $this->localization,
]);
}
diff --git a/resources/views/reports/time-entry-aggregate/pdf.blade.php b/resources/views/reports/time-entry-aggregate/pdf.blade.php
index 4681cf83..9fc4472e 100644
--- a/resources/views/reports/time-entry-aggregate/pdf.blade.php
+++ b/resources/views/reports/time-entry-aggregate/pdf.blade.php
@@ -194,7 +194,7 @@
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
- {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
+ {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
@endif
@@ -239,7 +239,7 @@
{{ $group->description() }}:
- {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
+ {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
@endif
@@ -278,7 +278,7 @@
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
- {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
+ {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
@endif
@@ -318,7 +318,7 @@
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'];
if ($color === null) {
$color = $colorService->getRandomColor($data['key']);
@@ -328,7 +328,7 @@
}
return (object)[
'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,
'itemStyle' => (object) [
'color' => $color,
diff --git a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
index 18e152ca..0dd514b2 100644
--- a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
+++ b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
@@ -44,7 +44,7 @@
|
@else
- {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
+ {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
|
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -53,7 +53,7 @@
@else
- {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
+ {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
|
@endif
@@ -74,7 +74,7 @@
|
@else
- {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
+ {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
|
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -83,7 +83,7 @@
@else
- {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
+ {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
|
@endif
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
{
// Arrange
diff --git a/tests/Unit/Service/LocalizationServiceTest.php b/tests/Unit/Service/LocalizationServiceTest.php
index 8904eebe..ea601fba 100644
--- a/tests/Unit/Service/LocalizationServiceTest.php
+++ b/tests/Unit/Service/LocalizationServiceTest.php
@@ -8,6 +8,7 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
+use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Service\LocalizationService;
use Brick\Money\Currency;
@@ -303,4 +304,39 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Assert
$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));
+ }
}
|