mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42: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->timezone = $timezone;
|
||||||
|
$properties->roundingType = $request->getPropertyRoundingType();
|
||||||
|
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||||
$report->properties = $properties;
|
$report->properties = $properties;
|
||||||
if ($isPublic) {
|
if ($isPublic) {
|
||||||
$report->share_secret = $reportService->generateSecret();
|
$report->share_secret = $reportService->generateSecret();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'timezone:all',
|
'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'));
|
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(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'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 */
|
/** @var string $created_at Date when the report was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'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",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.5.0",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.5.0",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ body {
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
--input: var(--theme-color-input-background);
|
--input: var(--color-border-tertiary);
|
||||||
--ring: var(--theme-color-ring);
|
--ring: var(--theme-color-ring);
|
||||||
--chart-1: var(--color-accent-400);
|
--chart-1: var(--color-accent-400);
|
||||||
--chart-2: var(--color-accent-500);
|
--chart-2: var(--color-accent-500);
|
||||||
@@ -232,7 +232,7 @@ body {
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
--input: var(--theme-color-input-background);
|
--input: var(--color-border-tertiary);
|
||||||
--ring: var(--theme-color-ring);
|
--ring: var(--theme-color-ring);
|
||||||
--chart-1: var(--color-accent-200);
|
--chart-1: var(--color-accent-200);
|
||||||
--chart-2: var(--color-accent-300);
|
--chart-2: var(--color-accent-300);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.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 TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.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 TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.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 { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting';
|
|||||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
|
||||||
|
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||||
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
@@ -71,6 +75,9 @@ const selectedTasks = ref<string[]>([]);
|
|||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
|
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
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 group = useStorage<GroupingOption>('reporting-group', 'project');
|
||||||
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
||||||
@@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore;
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
// Watch rounding enabled state to trigger updates
|
||||||
|
watch(roundingEnabled, () => {
|
||||||
|
updateReporting();
|
||||||
|
});
|
||||||
|
|
||||||
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
||||||
let params: AggregatedTimeEntriesQueryParams = {
|
let params: AggregatedTimeEntriesQueryParams = {
|
||||||
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
|
||||||
@@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
|
|||||||
getCurrentRole() === 'employee'
|
getCurrentRole() === 'employee'
|
||||||
? getCurrentMembershipId()
|
? getCurrentMembershipId()
|
||||||
: undefined,
|
: undefined,
|
||||||
|
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||||
|
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||||
};
|
};
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -395,6 +409,11 @@ const tableData = computed(() => {
|
|||||||
:icon="BillableIcon"></ReportingFilterBadge>
|
:icon="BillableIcon"></ReportingFilterBadge>
|
||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
|
<ReportingRoundingControls
|
||||||
|
v-model:enabled="roundingEnabled"
|
||||||
|
v-model:type="roundingType"
|
||||||
|
v-model:minutes="roundingMinutes"
|
||||||
|
@change="updateReporting"></ReportingRoundingControls>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
@@ -490,7 +509,7 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
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
|
No time entries found
|
||||||
</p>
|
</p>
|
||||||
<p>Try to change the filters and time range</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:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
outline:
|
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:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<SwitchRoot
|
<SwitchRoot
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn(
|
: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,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<SwitchThumb
|
<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" />
|
<slot name="thumb" />
|
||||||
</SwitchThumb>
|
</SwitchThumb>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.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 { computed, onMounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
@@ -69,6 +70,9 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|||||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
|
||||||
|
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||||
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
@@ -83,9 +87,17 @@ const selectedMembers = ref<string[]>([]);
|
|||||||
const selectedTasks = ref<string[]>([]);
|
const selectedTasks = ref<string[]>([]);
|
||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
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 { members } = storeToRefs(useMembersStore());
|
||||||
const pageLimit = 15;
|
const pageLimit = 15;
|
||||||
|
|
||||||
|
// Watch rounding enabled state to trigger updates
|
||||||
|
watch(roundingEnabled, () => {
|
||||||
|
updateFilteredTimeEntries();
|
||||||
|
});
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
|
|
||||||
function getFilterAttributes() {
|
function getFilterAttributes() {
|
||||||
@@ -115,6 +127,8 @@ function getFilterAttributes() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||||
billable: billable.value !== null ? billable.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;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -359,7 +373,13 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
</div>
|
</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
|
<DateRangePicker
|
||||||
v-model:start="startDate"
|
v-model:start="startDate"
|
||||||
v-model:end="endDate"
|
v-model:end="endDate"
|
||||||
|
|||||||
@@ -123,7 +123,12 @@ export type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
|||||||
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||||
SolidTimeApi,
|
SolidTimeApi,
|
||||||
'getAggregatedTimeEntries'
|
'getAggregatedTimeEntries'
|
||||||
> & { start: string; end: string };
|
> & {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
rounding_type?: string;
|
||||||
|
rounding_minutes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type OrganizationResponse = ZodiosResponseByAlias<
|
export type OrganizationResponse = ZodiosResponseByAlias<
|
||||||
SolidTimeApi,
|
SolidTimeApi,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Controllers\Api\V1\ReportController;
|
use App\Http\Controllers\Api\V1\ReportController;
|
||||||
use App\Models\Report;
|
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
|
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user