Skip to content

Commit e3cfc15

Browse files
Onatcerkorridor
authored andcommitted
add rounding frontend to reports, and support for shared reports
1 parent 4b72663 commit e3cfc15

File tree

14 files changed

+351
-11
lines changed

14 files changed

+351
-11
lines changed

app/Http/Controllers/Api/V1/ReportController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ public function store(Organization $organization, ReportStoreRequest $request, T
107107
}
108108
}
109109
$properties->timezone = $timezone;
110+
$properties->roundingType = $request->getPropertyRoundingType();
111+
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
110112
$report->properties = $properties;
111113
if ($isPublic) {
112114
$report->share_secret = $reportService->generateSecret();

app/Http/Requests/V1/Report/ReportStoreRequest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Enums\TimeEntryAggregationType;
88
use App\Enums\TimeEntryAggregationTypeInterval;
9+
use App\Enums\TimeEntryRoundingType;
910
use App\Enums\Weekday;
1011
use App\Http\Requests\V1\BaseFormRequest;
1112
use App\Models\Organization;
@@ -128,6 +129,18 @@ public function rules(): array
128129
'nullable',
129130
'timezone:all',
130131
],
132+
// 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.
133+
'properties.rounding_type' => [
134+
'nullable',
135+
'string',
136+
Rule::enum(TimeEntryRoundingType::class),
137+
],
138+
// Defines the length of the interval that the time entry rounding rounds to.
139+
'properties.rounding_minutes' => [
140+
'nullable',
141+
'numeric',
142+
'integer',
143+
],
131144
];
132145
}
133146

@@ -205,4 +218,22 @@ public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval
205218
{
206219
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
207220
}
221+
222+
public function getPropertyRoundingType(): ?TimeEntryRoundingType
223+
{
224+
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
225+
return null;
226+
}
227+
228+
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
229+
}
230+
231+
public function getPropertyRoundingMinutes(): ?int
232+
{
233+
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
234+
return null;
235+
}
236+
237+
return (int) $this->input('properties.rounding_minutes');
238+
}
208239
}

app/Http/Resources/V1/Report/DetailedReportResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ public function toArray(Request $request): array
5858
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
5959
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
6060
'task_ids' => $this->resource->properties->taskIds?->toArray(),
61+
/** @var string|null $rounding_type Rounding type for time entries */
62+
'rounding_type' => $this->resource->properties->roundingType?->value,
63+
/** @var int|null $rounding_minutes Rounding minutes for time entries */
64+
'rounding_minutes' => $this->resource->properties->roundingMinutes,
6165
],
6266
/** @var string $created_at Date when the report was created */
6367
'created_at' => $this->formatDateTime($this->resource->created_at),

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@tanstack/vue-table": "^8.21.2",
4747
"@vue/eslint-config-prettier": "^10.2.0",
4848
"@vue/eslint-config-typescript": "^14.3.0",
49-
"@vueuse/core": "^12.5.0",
49+
"@vueuse/core": "^12.8.2",
5050
"@vueuse/integrations": "^12.5.0",
5151
"class-variance-authority": "^0.7.1",
5252
"clsx": "^2.1.1",

resources/css/app.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ body {
205205
--destructive: 0 84.2% 60.2%;
206206
--destructive-foreground: var(--color-text-primary);
207207
--border: var(--color-border-primary);
208-
--input: var(--theme-color-input-background);
208+
--input: var(--color-border-tertiary);
209209
--ring: var(--theme-color-ring);
210210
--chart-1: var(--color-accent-400);
211211
--chart-2: var(--color-accent-500);
@@ -232,7 +232,7 @@ body {
232232
--destructive: 0 62.8% 30.6%;
233233
--destructive-foreground: var(--color-text-primary);
234234
--border: var(--color-border-primary);
235-
--input: var(--theme-color-input-background);
235+
--input: var(--color-border-tertiary);
236236
--ring: var(--theme-color-ring);
237237
--chart-1: var(--color-accent-200);
238238
--chart-2: var(--color-accent-300);

resources/js/Components/Common/Reporting/ReportingOverview.vue

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { formatCents } from '@/packages/ui/src/utils/money';
1717
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
1818
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
19+
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
1920
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
2021
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
2122
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
@@ -33,7 +34,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
3334
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
3435
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
3536
36-
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
37+
import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue';
3738
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
3839
import { storeToRefs } from 'pinia';
3940
import {
@@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting';
5455
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
5556
import { useProjectsStore } from '@/utils/useProjects';
5657
58+
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
59+
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
60+
5761
const { handleApiRequestNotifications } = useNotificationsStore();
5862
5963
const startDate = useSessionStorage<string>(
@@ -71,6 +75,9 @@ const selectedTasks = ref<string[]>([]);
7175
const selectedClients = ref<string[]>([]);
7276
7377
const billable = ref<'true' | 'false' | null>(null);
78+
const roundingEnabled = ref<boolean>(false);
79+
const roundingType = ref<TimeEntryRoundingType>('nearest');
80+
const roundingMinutes = ref<number>(15);
7481
7582
const group = useStorage<GroupingOption>('reporting-group', 'project');
7683
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
@@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore;
8491
8592
const organization = inject<ComputedRef<Organization>>('organization');
8693
94+
// Watch rounding enabled state to trigger updates
95+
watch(roundingEnabled, () => {
96+
updateReporting();
97+
});
98+
8799
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
88100
let params: AggregatedTimeEntriesQueryParams = {
89101
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
@@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
111123
getCurrentRole() === 'employee'
112124
? getCurrentMembershipId()
113125
: undefined,
126+
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
127+
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
114128
};
115129
return params;
116130
}
@@ -395,6 +409,11 @@ const tableData = computed(() => {
395409
:icon="BillableIcon"></ReportingFilterBadge>
396410
</template>
397411
</SelectDropdown>
412+
<ReportingRoundingControls
413+
v-model:enabled="roundingEnabled"
414+
v-model:type="roundingType"
415+
v-model:minutes="roundingMinutes"
416+
@change="updateReporting"></ReportingRoundingControls>
398417
</div>
399418
<div>
400419
<DateRangePicker
@@ -490,7 +509,7 @@ const tableData = computed(() => {
490509
<div
491510
v-else
492511
class="chart flex flex-col items-center justify-center py-12 col-span-3">
493-
<p class="text-lg text-text-primary font-semibold">
512+
<p class="text-lg text-text-primary font-medium">
494513
No time entries found
495514
</p>
496515
<p>Try to change the filters and time range</p>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<script setup lang="ts">
2+
import { Switch } from '@/Components/ui/switch';
3+
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
4+
import { Button } from '@/Components/ui/button';
5+
import {
6+
Select,
7+
SelectContent,
8+
SelectItem,
9+
SelectTrigger,
10+
SelectValue
11+
} from '@/Components/ui/select';
12+
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
13+
import {
14+
NumberField,
15+
NumberFieldInput,
16+
NumberFieldContent,
17+
NumberFieldIncrement,
18+
NumberFieldDecrement
19+
} from '@/Components/ui/number-field';
20+
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
21+
import { computed, ref, watch } from 'vue';
22+
// TimeEntryRoundingType definition
23+
const TimeEntryRoundingType = {
24+
Up: 'up' as const,
25+
Down: 'down' as const,
26+
Nearest: 'nearest' as const,
27+
} as const;
28+
29+
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType];
30+
31+
interface Props {
32+
enabled: boolean;
33+
type: TimeEntryRoundingType;
34+
minutes: number;
35+
}
36+
37+
const props = defineProps<Props>();
38+
39+
const emit = defineEmits<{
40+
'update:enabled': [value: boolean];
41+
'update:type': [value: TimeEntryRoundingType];
42+
'update:minutes': [value: number];
43+
'change': [];
44+
}>();
45+
46+
function updateEnabled(value: boolean) {
47+
emit('update:enabled', value);
48+
emit('change');
49+
}
50+
51+
function updateType(value: TimeEntryRoundingType) {
52+
emit('update:type', value);
53+
emit('change');
54+
}
55+
56+
function updateMinutes(value: number) {
57+
emit('update:minutes', value);
58+
emit('change');
59+
}
60+
61+
// Predefined intervals
62+
const predefinedIntervals = [
63+
{ value: '5', label: '5 minutes' },
64+
{ value: '6', label: '6 minutes' },
65+
{ value: '10', label: '10 minutes' },
66+
{ value: '15', label: '15 minutes' },
67+
{ value: '30', label: '30 minutes' },
68+
{ value: '60', label: '1 hour' },
69+
{ value: 'custom', label: 'Custom' },
70+
];
71+
72+
const showCustomInput = ref(false);
73+
const customMinutes = ref(props.minutes);
74+
const selectedInterval = ref('');
75+
76+
// Compute the current interval value based on props
77+
const currentInterval = computed(() => {
78+
const predefined = predefinedIntervals.find(interval =>
79+
interval.value !== 'custom' && parseInt(interval.value) === props.minutes
80+
);
81+
return predefined ? predefined.value : 'custom';
82+
});
83+
84+
// Initialize selectedInterval
85+
const initializeSelectedInterval = () => {
86+
selectedInterval.value = currentInterval.value;
87+
showCustomInput.value = selectedInterval.value === 'custom';
88+
if (showCustomInput.value) {
89+
customMinutes.value = props.minutes;
90+
}
91+
};
92+
93+
function handleIntervalChange(value: string) {
94+
selectedInterval.value = value;
95+
if (value === 'custom') {
96+
showCustomInput.value = true;
97+
// Update minutes to current custom value to ensure "custom" shows as selected
98+
updateMinutes(customMinutes.value);
99+
} else {
100+
showCustomInput.value = false;
101+
const minutes = parseInt(value);
102+
updateMinutes(minutes);
103+
}
104+
}
105+
106+
function handleCustomMinutesChange(value: string | number) {
107+
const numValue = typeof value === 'string' ? parseInt(value) : value;
108+
if (!isNaN(numValue) && numValue > 0) {
109+
customMinutes.value = numValue;
110+
updateMinutes(numValue);
111+
}
112+
}
113+
114+
// Watch for changes in props.minutes
115+
watch(() => props.minutes, (newMinutes) => {
116+
customMinutes.value = newMinutes;
117+
initializeSelectedInterval();
118+
}, { immediate: true });
119+
120+
watch(currentInterval, () => {
121+
initializeSelectedInterval();
122+
});
123+
</script>
124+
125+
<template>
126+
<Popover>
127+
<PopoverTrigger as-child>
128+
<Button
129+
variant="outline"
130+
size="sm"
131+
class="text-sm">
132+
<ArrowsUpDownIcon class="w-4 h-4" :class="enabled ? 'text-primary' : 'text-muted-foreground opacity-50'" />
133+
Rounding {{ enabled ? 'on' : 'off' }}
134+
</Button>
135+
</PopoverTrigger>
136+
<PopoverContent class="w-72 p-4">
137+
<div class="space-y-4">
138+
<div class="flex items-center justify-between">
139+
<InputLabel for="enable-rounding" value="Enable Rounding" />
140+
<Switch
141+
id="enable-rounding"
142+
:model-value="enabled"
143+
class="data-[state=checked]:bg-accent-500"
144+
@update:model-value="updateEnabled" />
145+
</div>
146+
147+
<div>
148+
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
149+
<Select
150+
:model-value="type"
151+
:disabled="!enabled"
152+
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
153+
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled">
154+
<SelectValue placeholder="Select rounding type" />
155+
</SelectTrigger>
156+
<SelectContent>
157+
<SelectItem value="up">Round Up</SelectItem>
158+
<SelectItem value="down">Round Down</SelectItem>
159+
<SelectItem value="nearest">Round Nearest</SelectItem>
160+
</SelectContent>
161+
</Select>
162+
</div>
163+
<div>
164+
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
165+
<Select
166+
:model-value="selectedInterval"
167+
:disabled="!enabled"
168+
@update:model-value="(value) => handleIntervalChange(value as string)">
169+
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled">
170+
<SelectValue placeholder="Select interval" />
171+
</SelectTrigger>
172+
<SelectContent>
173+
<SelectItem
174+
v-for="interval in predefinedIntervals"
175+
:key="interval.value"
176+
:value="interval.value">
177+
{{ interval.label }}
178+
</SelectItem>
179+
</SelectContent>
180+
</Select>
181+
182+
<div v-if="showCustomInput" class="mt-2">
183+
<NumberField
184+
id="custom-minutes"
185+
:model-value="customMinutes"
186+
size="small"
187+
:min="1"
188+
:max="1440"
189+
:disabled="!enabled"
190+
class="text-sm"
191+
@update:model-value="handleCustomMinutesChange">
192+
<NumberFieldContent>
193+
<NumberFieldDecrement :disabled="!enabled" />
194+
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" />
195+
<NumberFieldIncrement :disabled="!enabled" />
196+
</NumberFieldContent>
197+
</NumberField>
198+
</div>
199+
</div>
200+
</div>
201+
</PopoverContent>
202+
</Popover>
203+
</template>

resources/js/Components/ui/button/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
1111
destructive:
1212
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
1313
outline:
14-
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
14+
'border shadow-xs hover:text-accent-foreground border-input dark:border-input hover:bg-white/15',
1515
secondary:
1616
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
1717
ghost: 'hover:bg-accent hover:text-accent-foreground',

0 commit comments

Comments
 (0)