mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22: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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user