mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add rounding frontend to reports, and support for shared reports
This commit is contained in:
committed by
Constantin Graf
parent
4b726635b2
commit
e3cfc155b8
@@ -107,6 +107,8 @@ class ReportController extends Controller
|
||||
}
|
||||
}
|
||||
$properties->timezone = $timezone;
|
||||
$properties->roundingType = $request->getPropertyRoundingType();
|
||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||
$report->properties = $properties;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
'nullable',
|
||||
'timezone:all',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'properties.rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'properties.rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
{
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingMinutes(): ?int
|
||||
{
|
||||
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->input('properties.rounding_minutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
|
||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
/** @var string|null $rounding_type Rounding type for time entries */
|
||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||
],
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -205,7 +205,7 @@ body {
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
@@ -232,7 +232,7 @@ body {
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--theme-color-input-background);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||
@@ -33,7 +34,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||
|
||||
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
|
||||
import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue';
|
||||
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import {
|
||||
@@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting';
|
||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
|
||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
const startDate = useSessionStorage<string>(
|
||||
@@ -71,6 +75,9 @@ const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
const roundingMinutes = ref<number>(15);
|
||||
|
||||
const group = useStorage<GroupingOption>('reporting-group', 'project');
|
||||
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
||||
@@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore;
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
// Watch rounding enabled state to trigger updates
|
||||
watch(roundingEnabled, () => {
|
||||
updateReporting();
|
||||
});
|
||||
|
||||
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||
let params: AggregatedTimeEntriesQueryParams = {
|
||||
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
||||
@@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||
getCurrentRole() === 'employee'
|
||||
? getCurrentMembershipId()
|
||||
: undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -395,6 +409,11 @@ const tableData = computed(() => {
|
||||
:icon="BillableIcon"></ReportingFilterBadge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<ReportingRoundingControls
|
||||
v-model:enabled="roundingEnabled"
|
||||
v-model:type="roundingType"
|
||||
v-model:minutes="roundingMinutes"
|
||||
@change="updateReporting"></ReportingRoundingControls>
|
||||
</div>
|
||||
<div>
|
||||
<DateRangePicker
|
||||
@@ -490,7 +509,7 @@ const tableData = computed(() => {
|
||||
<div
|
||||
v-else
|
||||
class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
||||
<p class="text-lg text-text-primary font-semibold">
|
||||
<p class="text-lg text-text-primary font-medium">
|
||||
No time entries found
|
||||
</p>
|
||||
<p>Try to change the filters and time range</p>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/Components/ui/select';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldInput,
|
||||
NumberFieldContent,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldDecrement
|
||||
} from '@/Components/ui/number-field';
|
||||
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
// TimeEntryRoundingType definition
|
||||
const TimeEntryRoundingType = {
|
||||
Up: 'up' as const,
|
||||
Down: 'down' as const,
|
||||
Nearest: 'nearest' as const,
|
||||
} as const;
|
||||
|
||||
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType];
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
type: TimeEntryRoundingType;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean];
|
||||
'update:type': [value: TimeEntryRoundingType];
|
||||
'update:minutes': [value: number];
|
||||
'change': [];
|
||||
}>();
|
||||
|
||||
function updateEnabled(value: boolean) {
|
||||
emit('update:enabled', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function updateType(value: TimeEntryRoundingType) {
|
||||
emit('update:type', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function updateMinutes(value: number) {
|
||||
emit('update:minutes', value);
|
||||
emit('change');
|
||||
}
|
||||
|
||||
// Predefined intervals
|
||||
const predefinedIntervals = [
|
||||
{ value: '5', label: '5 minutes' },
|
||||
{ value: '6', label: '6 minutes' },
|
||||
{ value: '10', label: '10 minutes' },
|
||||
{ value: '15', label: '15 minutes' },
|
||||
{ value: '30', label: '30 minutes' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
const showCustomInput = ref(false);
|
||||
const customMinutes = ref(props.minutes);
|
||||
const selectedInterval = ref('');
|
||||
|
||||
// Compute the current interval value based on props
|
||||
const currentInterval = computed(() => {
|
||||
const predefined = predefinedIntervals.find(interval =>
|
||||
interval.value !== 'custom' && parseInt(interval.value) === props.minutes
|
||||
);
|
||||
return predefined ? predefined.value : 'custom';
|
||||
});
|
||||
|
||||
// Initialize selectedInterval
|
||||
const initializeSelectedInterval = () => {
|
||||
selectedInterval.value = currentInterval.value;
|
||||
showCustomInput.value = selectedInterval.value === 'custom';
|
||||
if (showCustomInput.value) {
|
||||
customMinutes.value = props.minutes;
|
||||
}
|
||||
};
|
||||
|
||||
function handleIntervalChange(value: string) {
|
||||
selectedInterval.value = value;
|
||||
if (value === 'custom') {
|
||||
showCustomInput.value = true;
|
||||
// Update minutes to current custom value to ensure "custom" shows as selected
|
||||
updateMinutes(customMinutes.value);
|
||||
} else {
|
||||
showCustomInput.value = false;
|
||||
const minutes = parseInt(value);
|
||||
updateMinutes(minutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomMinutesChange(value: string | number) {
|
||||
const numValue = typeof value === 'string' ? parseInt(value) : value;
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
customMinutes.value = numValue;
|
||||
updateMinutes(numValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in props.minutes
|
||||
watch(() => props.minutes, (newMinutes) => {
|
||||
customMinutes.value = newMinutes;
|
||||
initializeSelectedInterval();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(currentInterval, () => {
|
||||
initializeSelectedInterval();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-sm">
|
||||
<ArrowsUpDownIcon class="w-4 h-4" :class="enabled ? 'text-primary' : 'text-muted-foreground opacity-50'" />
|
||||
Rounding {{ enabled ? 'on' : 'off' }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-4">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
||||
<Switch
|
||||
id="enable-rounding"
|
||||
:model-value="enabled"
|
||||
class="data-[state=checked]:bg-accent-500"
|
||||
@update:model-value="updateEnabled" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
|
||||
<Select
|
||||
:model-value="type"
|
||||
:disabled="!enabled"
|
||||
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
|
||||
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled">
|
||||
<SelectValue placeholder="Select rounding type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="up">Round Up</SelectItem>
|
||||
<SelectItem value="down">Round Down</SelectItem>
|
||||
<SelectItem value="nearest">Round Nearest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
|
||||
<Select
|
||||
:model-value="selectedInterval"
|
||||
:disabled="!enabled"
|
||||
@update:model-value="(value) => handleIntervalChange(value as string)">
|
||||
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="interval in predefinedIntervals"
|
||||
:key="interval.value"
|
||||
:value="interval.value">
|
||||
{{ interval.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div v-if="showCustomInput" class="mt-2">
|
||||
<NumberField
|
||||
id="custom-minutes"
|
||||
:model-value="customMinutes"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:disabled="!enabled"
|
||||
class="text-sm"
|
||||
@update:model-value="handleCustomMinutesChange">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement :disabled="!enabled" />
|
||||
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" />
|
||||
<NumberFieldIncrement :disabled="!enabled" />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
'border shadow-xs hover:text-accent-foreground border-input dark:border-input hover:bg-white/15',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
|
||||
@@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SwitchRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-white bg-white/50',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0')"
|
||||
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4')"
|
||||
>
|
||||
<slot name="thumb" />
|
||||
</SwitchThumb>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
@@ -69,6 +70,9 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||
|
||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
const startDate = useSessionStorage<string>(
|
||||
'reporting-start-date',
|
||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||
@@ -83,9 +87,17 @@ const selectedMembers = ref<string[]>([]);
|
||||
const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
const roundingMinutes = ref<number>(15);
|
||||
|
||||
const { members } = storeToRefs(useMembersStore());
|
||||
const pageLimit = 15;
|
||||
|
||||
// Watch rounding enabled state to trigger updates
|
||||
watch(roundingEnabled, () => {
|
||||
updateFilteredTimeEntries();
|
||||
});
|
||||
const currentPage = ref(1);
|
||||
|
||||
function getFilterAttributes() {
|
||||
@@ -115,6 +127,8 @@ function getFilterAttributes() {
|
||||
: undefined,
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -359,7 +373,13 @@ async function downloadExport(format: ExportFormat) {
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<ReportingRoundingControls
|
||||
v-model:enabled="roundingEnabled"
|
||||
v-model:type="roundingType"
|
||||
v-model:minutes="roundingMinutes"
|
||||
@change="updateFilteredTimeEntries" />
|
||||
|
||||
<DateRangePicker
|
||||
v-model:start="startDate"
|
||||
v-model:end="endDate"
|
||||
|
||||
@@ -123,7 +123,12 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||
SolidTimeApi,
|
||||
'getAggregatedTimeEntries'
|
||||
> & { start: string; end: string };
|
||||
> & {
|
||||
start: string;
|
||||
end: string;
|
||||
rounding_type?: string;
|
||||
rounding_minutes?: number;
|
||||
};
|
||||
|
||||
export type OrganizationResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Controllers\Api\V1\ReportController;
|
||||
use App\Models\Report;
|
||||
@@ -162,6 +163,61 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
|
||||
);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_report_with_rounding_properties(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'reports:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Test Report with Rounding',
|
||||
'description' => 'Test description',
|
||||
'is_public' => true,
|
||||
'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),
|
||||
'properties' => [
|
||||
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||
'active' => true,
|
||||
'member_ids' => [],
|
||||
'billable' => true,
|
||||
'client_ids' => [],
|
||||
'project_ids' => [],
|
||||
'tag_ids' => [],
|
||||
'task_ids' => [],
|
||||
'group' => TimeEntryAggregationType::Project->value,
|
||||
'sub_group' => TimeEntryAggregationType::Task->value,
|
||||
'history_group' => TimeEntryAggregationType::Day->value,
|
||||
'week_start' => Weekday::Monday->value,
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'rounding_type' => 'nearest',
|
||||
'rounding_minutes' => 15,
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
/** @var Report $report */
|
||||
$report = Report::query()->findOrFail($response->json('data.id'));
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.name', 'Test Report with Rounding')
|
||||
->where('data.description', 'Test description')
|
||||
->where('data.is_public', true)
|
||||
->where('data.shareable_link', $report->getShareableLink())
|
||||
->where('data.properties.group', TimeEntryAggregationType::Project->value)
|
||||
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
|
||||
->where('data.properties.rounding_type', 'nearest')
|
||||
->where('data.properties.rounding_minutes', 15)
|
||||
);
|
||||
|
||||
// Also verify the properties are saved in the database
|
||||
$this->assertSame(TimeEntryRoundingType::Nearest, $report->properties->roundingType);
|
||||
$this->assertSame(15, $report->properties->roundingMinutes);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user