Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
231d22a5ce add prevent_overlapping_time_entries setting to organization
when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
2025-10-03 14:59:45 +02:00
19 changed files with 296 additions and 524 deletions

View File

@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -38,17 +38,11 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -63,18 +62,6 @@ class Client extends Model implements AuditableContract
return $this->hasMany(Project::class, 'client_id');
}
/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}
/**
* @return Attribute<bool, never>
*/

View File

@@ -109,7 +109,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -173,7 +172,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -234,7 +232,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -259,13 +256,12 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');

View File

@@ -10,7 +10,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
@@ -18,7 +17,6 @@ use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TimeEntryAggregationService
@@ -47,21 +45,9 @@ class TimeEntryAggregationService
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;
/** @var Builder<TimeEntry> $baseTotalsQuery */
$baseTotalsQuery = $timeEntriesQuery->clone();
$group1Select = null;
$group2Select = null;
$groupBy = null;
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
$timeEntriesQuery->crossJoin(DB::raw(
"LATERAL (\n".
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
" UNION ALL\n".
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
') AS tag(tag)'
));
}
if ($group1Type !== null) {
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
$groupBy = ['group_1'];
@@ -98,26 +84,6 @@ class TimeEntryAggregationService
$group1Response = [];
$group1ResponseSum = 0;
$group1ResponseCost = 0;
// If Tag is subgroup, prepare base totals per primary group without tag expansion
$baseTotalsPerGroup1Map = [];
if ($group2Type === TimeEntryAggregationType::Tag) {
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw(
$group1Select.' as group_1,'.
' 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'
)
->groupBy('group_1')
->get();
foreach ($baseTotalsPerGroup1 as $row) {
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
'aggregate' => (int) ($row->aggregate ?? 0),
'cost' => (int) ($row->cost ?? 0),
];
}
}
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
/** @var string|int $group1 */
$group2Response = [];
@@ -137,14 +103,6 @@ class TimeEntryAggregationService
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
$group2ResponseCost += (int) $aggregate->get(0)->cost;
}
// Override primary group totals when Tag is subgroup to avoid double counting
if ($group2Type === TimeEntryAggregationType::Tag) {
$keyForMap = (string) $group1;
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
}
}
} else {
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
@@ -163,23 +121,6 @@ class TimeEntryAggregationService
$group1ResponseCost += $group2ResponseCost;
}
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
if ($hasTagGrouping) {
// Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery
->selectRaw(
' 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'
)
->first();
if ($baseTotals !== null) {
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
}
}
if ($fillGapsInTimeGroupsIsPossible) {
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
}
@@ -353,17 +294,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
->select('id', 'name')
->get();
foreach ($tags as $tag) {
$descriptorMap[$tag->id] = [
'description' => $tag->name,
'color' => null,
];
}
}
return $descriptorMap;
@@ -506,8 +436,6 @@ class TimeEntryAggregationService
return 'billable';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {
return 'tag';
}
}

View File

@@ -113,7 +113,7 @@ const option = computed(() => ({
},
axisLabel: {
fontSize: 12,
fontWeight: 400,
fontWeight: 600,
color: labelColor.value,
margin: 16,
fontFamily: 'Inter, sans-serif',

View File

@@ -30,7 +30,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
<template>
<div
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
<div
:class="
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
">
<GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
:expanded="expanded"

View File

@@ -27,10 +27,9 @@ onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
getDayJsInstance()().tz(timezone.value).format() !==
getDayJsInstance()().tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;

View File

@@ -400,7 +400,6 @@ async function downloadExport(format: ExportFormat) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:currency="getOrganizationCurrencyString()"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
show-date
show-member

View File

@@ -36,14 +36,20 @@ const ClientResource = z
const ClientCollection = z.array(ClientResource);
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.object({
name: z.string().min(1).max(255),
is_archived: z.boolean().optional(),
})
.passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough();
const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough();
const InvoiceResource = z
.object({
@@ -91,7 +97,6 @@ const InvoiceStoreRequest = z
billing_period_end: z.union([z.string(), z.null()]).optional(),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]).optional(),
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
discount_type: InvoiceDiscountType.optional(),
@@ -156,7 +161,6 @@ const DetailedInvoiceResource = z
discount_type: z.string(),
discount_amount: z.number().int(),
tax_rate: z.number().int(),
payment_iban: z.string(),
status: z.string(),
currency: z.string(),
date: z.string(),
@@ -202,7 +206,6 @@ const InvoiceUpdateRequest = z
billing_period_end: z.union([z.string(), z.null()]),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]),
tax_rate: z.number().int().gte(0).lte(2147483647),
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
discount_type: InvoiceDiscountType,
@@ -387,7 +390,10 @@ const ProjectMemberResource = z
})
.passthrough();
const ProjectMemberStoreRequest = z
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
.object({
member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
@@ -416,7 +422,6 @@ const TimeEntryAggregationType = z.enum([
'client',
'billable',
'description',
'tag',
]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
@@ -428,7 +433,6 @@ const Weekday = z.enum([
'saturday',
'sunday',
]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z
.object({
name: z.string().max(255),
@@ -451,8 +455,6 @@ const ReportStoreRequest = z
history_group: TimeEntryAggregationTypeInterval,
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
rounding_type: TimeEntryRoundingType.optional(),
rounding_minutes: z.union([z.number(), z.null()]).optional(),
})
.passthrough(),
})
@@ -479,8 +481,6 @@ const DetailedReportResource = z
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
})
.passthrough(),
created_at: z.string(),
@@ -594,7 +594,12 @@ const DetailedWithDataReportResource = z
})
.passthrough();
const TagResource = z
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const TagCollection = z.array(TagResource);
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
@@ -626,7 +631,6 @@ const TaskUpdateRequest = z
})
.passthrough();
const start = z.union([z.string(), z.null()]).optional();
const rounding_minutes = z.union([z.number(), z.null()]).optional();
const TimeEntryResource = z
.object({
id: z.string(),
@@ -747,7 +751,6 @@ export const schemas = {
TimeEntryAggregationType,
TimeEntryAggregationTypeInterval,
Weekday,
TimeEntryRoundingType,
ReportStoreRequest,
DetailedReportResource,
ReportUpdateRequest,
@@ -760,7 +763,6 @@ export const schemas = {
TaskStoreRequest,
TaskUpdateRequest,
start,
rounding_minutes,
TimeEntryResource,
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
@@ -790,7 +792,13 @@ const endpoints = makeApi([
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
),
},
{
@@ -862,7 +870,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1157,7 +1168,13 @@ const endpoints = makeApi([
},
],
response: z.array(
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
z
.object({
value: z.number().int(),
name: z.string(),
color: z.string(),
})
.passthrough()
),
errors: [
{
@@ -1220,7 +1237,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1263,7 +1283,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1311,7 +1334,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1339,7 +1365,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1377,7 +1407,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1433,7 +1467,7 @@ const endpoints = makeApi([
status: 400,
schema: z.union([
z.object({ message: z.string() }).passthrough(),
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
z.object({ message: z.string() }).passthrough(),
]),
},
{
@@ -1455,7 +1489,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1476,7 +1513,11 @@ const endpoints = makeApi([
.object({
data: z.array(
z
.object({ key: z.string(), name: z.string(), description: z.string() })
.object({
key: z.string(),
name: z.string(),
description: z.string(),
})
.passthrough()
),
})
@@ -1564,7 +1605,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1592,7 +1636,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -1614,7 +1662,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1760,7 +1811,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1803,7 +1857,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1846,7 +1903,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1930,7 +1990,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -1995,13 +2058,6 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2021,7 +2077,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2045,13 +2104,6 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2097,7 +2149,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2119,7 +2175,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2189,7 +2248,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2222,7 +2284,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2244,7 +2310,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2277,7 +2346,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2299,7 +2372,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2327,7 +2403,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2370,7 +2450,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2433,7 +2517,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2549,7 +2636,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2592,7 +2682,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2676,7 +2769,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2704,7 +2800,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2820,7 +2920,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -2842,7 +2946,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -2948,7 +3055,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3032,7 +3142,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3142,7 +3255,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3190,7 +3306,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3218,7 +3337,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3313,7 +3436,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3356,7 +3482,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3404,7 +3533,10 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3432,7 +3564,11 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3505,16 +3641,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'user_id',
type: 'Query',
@@ -3572,7 +3698,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3600,7 +3729,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3622,7 +3755,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3665,7 +3801,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3708,7 +3847,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3741,7 +3883,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -3763,7 +3909,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -3833,7 +3982,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -3852,7 +4000,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -3891,16 +4038,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -3985,7 +4122,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4020,7 +4160,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4037,7 +4176,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4085,16 +4223,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4130,7 +4258,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4152,7 +4284,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4213,16 +4348,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4253,7 +4378,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4275,7 +4404,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4357,7 +4489,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4374,7 +4510,10 @@ Please note that the access token is only shown in this response and cannot be r
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
@@ -4397,7 +4536,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
@@ -4435,7 +4578,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{

View File

@@ -2,10 +2,9 @@
import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
date: Date;
totalMinutes?: number;
}>();
@@ -21,7 +20,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
<template>
<div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium">
{{ date.format('ddd') }}
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
</div>
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">

View File

@@ -250,7 +250,7 @@ const calendarOptions = computed(() => ({
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
timeZone: 'America/Adak',
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
@@ -332,16 +332,9 @@ watch(showEditTimeEntryModal, (value) => {
</template>
<template #dayHeaderContent="arg">
<FullCalendarDayHeader
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:date="arg.date"
:total-minutes="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
.tz(getUserTimezone(), true)
.format('YYYY-MM-DD')
] || 0
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
" />
</template>
</FullCalendar>

View File

@@ -33,7 +33,6 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void;
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -174,7 +173,6 @@ function onSelectChange(checked: boolean) {
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-edit="false"
:show-duplicate="false"
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])
"></TimeEntryMoreOptionsDropdown>
@@ -204,7 +202,6 @@ function onSelectChange(checked: boolean) {
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
:on-start-stop-click="() => onStartStopClick(subEntry)"
:delete-time-entry="() => deleteTimeEntries([subEntry])"
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency"
:create-tag
:time-entry="subEntry"

View File

@@ -108,7 +108,6 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
@@ -159,7 +158,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries
:update-time-entry
:delete-time-entries
@@ -200,7 +198,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:currency="currency"
:time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,10 +10,8 @@ import {
const props = withDefaults(
defineProps<{
showEdit?: boolean;
showDuplicate?: boolean;
}>(),
{
showDuplicate: true,
showEdit: true,
}
);
@@ -21,7 +19,6 @@ const props = withDefaults(
const emit = defineEmits<{
edit: [];
delete: [];
duplicate: [];
}>();
</script>
@@ -54,14 +51,6 @@ const emit = defineEmits<{
<PencilIcon class="w-5" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.showDuplicate"
data-testid="time_entry_duplicate"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('duplicate')">
<DocumentDuplicateIcon class="w-5" />
<span>Duplicate</span>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="time_entry_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"

View File

@@ -36,7 +36,6 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void;
deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
currency: string;
showMember?: boolean;
@@ -167,7 +166,6 @@ async function handleDeleteTimeEntry() {
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
</div>
</div>

View File

@@ -12,19 +12,11 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
export type GroupingOption =
| 'project'
| 'task'
| 'user'
| 'billable'
| 'client'
| 'description'
| 'tag';
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
@@ -81,7 +73,6 @@ export const useReportingStore = defineStore('reporting', () => {
billable: 'Non-Billable',
client: 'No Client',
description: 'No Description',
tag: 'No Tag',
} as Record<string, string>;
function getNameForReportingRowEntry(key: string | null, type: string | null) {
@@ -115,11 +106,6 @@ export const useReportingStore = defineStore('reporting', () => {
const { clients } = storeToRefs(clientsStore);
return clients.value.find((client) => client.id === key)?.name;
}
if (type === 'tag') {
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
return tags.value.find((tag) => tag.id === key)?.name;
}
if (type === 'billable') {
if (key === '0') {
return 'Non-Billable';
@@ -165,11 +151,6 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'description',
icon: DocumentTextIcon,
},
{
label: 'Tags',
value: 'tag',
icon: DocumentTextIcon,
},
];
return {

View File

@@ -34,7 +34,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
Passport::actingAs($data->user);
@@ -58,43 +57,11 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_returns_list_of_clients_assigned_to_employee_user(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$clients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithMembership1 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(0))->addMember($data->member)->isPrivate()->create();
$projectWithMembership2 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(1))->addMember($data->member)->isPrivate()->create();
$otherClients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithoutMembership = Project::factory()->forOrganization($data->organization)->forClient($otherClients->get(0))->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->count('data', 2)
->where('data.0.id', $clients->get(0)->getKey())
->where('data.1.id', $clients->get(1)->getKey())
);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -114,7 +81,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -137,7 +103,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -160,7 +125,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);

View File

@@ -9,7 +9,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService;
use Illuminate\Support\Carbon;
@@ -1008,201 +1007,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_group_by_tag_includes_no_tag_and_avoids_double_counting_overall(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags (100s)
TimeEntry::factory()->startWithDuration($start, 100)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
// One entry with one tag (50s)
TimeEntry::factory()->startWithDuration($start, 50)->create([
'tags' => [$tag1->getKey()],
]);
// One entry with no tags (25s)
TimeEntry::factory()->startWithDuration($start, 25)->create([
'tags' => [],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Tag,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert - overall total should be 175 and groups: null=25, tag1=150, tag2=100
$expected = [
'seconds' => 175,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => null,
'seconds' => 25,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag1->getKey(),
'seconds' => 150,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
TimeEntry::factory()->startWithDuration($start, 120)->forProject($project)->create([
'tags' => [$tag1->getKey()],
]);
TimeEntry::factory()->startWithDuration($start, 60)->forProject($project)->create([
'tags' => [$tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 120,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 60,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag_avoids_double_counting(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags => subgroup rows show both tags, but project total should equal entry duration
TimeEntry::factory()->startWithDuration($start, 100)->forProject($project)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
}