mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
add tag grouping
This commit is contained in:
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -45,9 +47,21 @@ 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'];
|
||||
@@ -84,6 +98,26 @@ 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 = [];
|
||||
@@ -103,6 +137,14 @@ 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;
|
||||
@@ -121,6 +163,23 @@ 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);
|
||||
}
|
||||
@@ -294,6 +353,17 @@ 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;
|
||||
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||
return 'description';
|
||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||
return 'tag';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const option = computed(() => ({
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontWeight: 400,
|
||||
color: labelColor.value,
|
||||
margin: 16,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
|
||||
@@ -30,10 +30,7 @@ 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 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||
">
|
||||
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
|
||||
<GroupedItemsCountButton
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||
:expanded="expanded"
|
||||
|
||||
@@ -36,20 +36,14 @@ 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({
|
||||
@@ -97,6 +91,7 @@ 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(),
|
||||
@@ -123,10 +118,10 @@ const InvoiceEntryResource = z
|
||||
id: z.string(),
|
||||
invoice_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.union([z.string(), z.null()]),
|
||||
unit_price: z.number().int(),
|
||||
description: z.string(),
|
||||
unit_price: z.string(),
|
||||
quantity: z.number(),
|
||||
order_index: z.number().int(),
|
||||
order_index: z.string(),
|
||||
created_at: z.union([z.string(), z.null()]),
|
||||
updated_at: z.union([z.string(), z.null()]),
|
||||
})
|
||||
@@ -161,6 +156,7 @@ 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(),
|
||||
@@ -206,6 +202,7 @@ 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,
|
||||
@@ -388,10 +385,7 @@ 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()]) })
|
||||
@@ -420,6 +414,7 @@ const TimeEntryAggregationType = z.enum([
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]);
|
||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||
const Weekday = z.enum([
|
||||
@@ -431,6 +426,7 @@ const Weekday = z.enum([
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
||||
const ReportStoreRequest = z
|
||||
.object({
|
||||
name: z.string().max(255),
|
||||
@@ -453,6 +449,8 @@ 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,6 +477,8 @@ 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(),
|
||||
@@ -592,12 +592,7 @@ 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();
|
||||
@@ -629,6 +624,7 @@ 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(),
|
||||
@@ -749,6 +745,7 @@ export const schemas = {
|
||||
TimeEntryAggregationType,
|
||||
TimeEntryAggregationTypeInterval,
|
||||
Weekday,
|
||||
TimeEntryRoundingType,
|
||||
ReportStoreRequest,
|
||||
DetailedReportResource,
|
||||
ReportUpdateRequest,
|
||||
@@ -761,6 +758,7 @@ export const schemas = {
|
||||
TaskStoreRequest,
|
||||
TaskUpdateRequest,
|
||||
start,
|
||||
rounding_minutes,
|
||||
TimeEntryResource,
|
||||
TimeEntryStoreRequest,
|
||||
TimeEntryUpdateMultipleRequest,
|
||||
@@ -790,13 +788,7 @@ 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()
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -868,10 +860,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1166,13 +1155,7 @@ 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: [
|
||||
{
|
||||
@@ -1235,10 +1218,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1281,10 +1261,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1332,10 +1309,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1363,11 +1337,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -1405,11 +1375,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -1465,7 +1431,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
schema: z.union([
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -1487,10 +1453,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1511,11 +1474,7 @@ 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()
|
||||
),
|
||||
})
|
||||
@@ -1603,10 +1562,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1634,11 +1590,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -1660,10 +1612,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1809,10 +1758,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1855,10 +1801,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1901,10 +1844,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -1923,7 +1863,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
schema: z.number().int(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
|
||||
@@ -1964,7 +1904,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
schema: z.number().int(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
|
||||
@@ -1988,10 +1928,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2010,7 +1947,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
schema: z.number().int(),
|
||||
},
|
||||
],
|
||||
response: z.void(),
|
||||
@@ -2051,11 +1988,18 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
schema: z.number().int(),
|
||||
},
|
||||
],
|
||||
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`,
|
||||
@@ -2075,10 +2019,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2097,11 +2038,18 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
schema: z.number().int(),
|
||||
},
|
||||
],
|
||||
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`,
|
||||
@@ -2147,11 +2095,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2173,10 +2117,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2246,10 +2187,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2282,11 +2220,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2308,10 +2242,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2344,11 +2275,7 @@ 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,10 +2297,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2401,11 +2325,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2448,11 +2368,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2515,10 +2431,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2634,10 +2547,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2680,10 +2590,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2767,10 +2674,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -2798,11 +2702,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2918,11 +2818,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -2944,10 +2840,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3053,10 +2946,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3140,10 +3030,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3253,10 +3140,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3304,10 +3188,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3335,11 +3216,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -3434,10 +3311,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3480,10 +3354,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3531,10 +3402,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -3562,11 +3430,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -3639,6 +3503,16 @@ Users with the permission `time-entries:view:own` 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',
|
||||
@@ -3696,10 +3570,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
],
|
||||
@@ -3727,11 +3598,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
{
|
||||
@@ -3753,10 +3620,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
],
|
||||
@@ -3799,10 +3663,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
],
|
||||
@@ -3845,10 +3706,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
],
|
||||
@@ -3881,11 +3739,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
{
|
||||
@@ -3907,10 +3761,7 @@ Users with the permission `time-entries:view:own` 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(),
|
||||
},
|
||||
],
|
||||
@@ -3980,6 +3831,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -3998,6 +3850,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -4036,6 +3889,16 @@ If the group parameters are all set to `null` 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',
|
||||
@@ -4120,10 +3983,7 @@ If the group parameters are all set to `null` 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(),
|
||||
},
|
||||
],
|
||||
@@ -4158,6 +4018,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4174,6 +4035,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4221,6 +4083,16 @@ If the group parameters are all set to `null` 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',
|
||||
@@ -4256,11 +4128,7 @@ If the group parameters are all set to `null` 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(),
|
||||
},
|
||||
{
|
||||
@@ -4282,10 +4150,7 @@ If the group parameters are all set to `null` 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(),
|
||||
},
|
||||
],
|
||||
@@ -4346,6 +4211,16 @@ If the group parameters are all set to `null` 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',
|
||||
@@ -4376,11 +4251,7 @@ If the group parameters are all set to `null` 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(),
|
||||
},
|
||||
{
|
||||
@@ -4402,10 +4273,7 @@ If the group parameters are all set to `null` 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(),
|
||||
},
|
||||
],
|
||||
@@ -4487,11 +4355,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -4508,10 +4372,7 @@ 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(),
|
||||
},
|
||||
],
|
||||
@@ -4534,11 +4395,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
@@ -4576,11 +4433,7 @@ 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(),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,11 +12,19 @@ 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';
|
||||
export type GroupingOption =
|
||||
| 'project'
|
||||
| 'task'
|
||||
| 'user'
|
||||
| 'billable'
|
||||
| 'client'
|
||||
| 'description'
|
||||
| 'tag';
|
||||
|
||||
export const useReportingStore = defineStore('reporting', () => {
|
||||
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||
@@ -73,6 +81,7 @@ 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) {
|
||||
@@ -106,6 +115,11 @@ 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';
|
||||
@@ -151,6 +165,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
value: 'description',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
value: 'tag',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -1007,4 +1008,201 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user