improve reporting page responsive layout; standardize button sizing;

prevent mobile input zoom; increase CI playwright shards
This commit is contained in:
Gregor Vostrak
2026-02-12 13:30:11 +01:00
parent 685cc29282
commit b258717211
14 changed files with 226 additions and 29 deletions

View File

@@ -9,8 +9,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4] shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [4] shardTotal: [8]
services: services:
mailpit: mailpit:

View File

@@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
/* Run tests in parallel */ /* Run tests in parallel */
workers: 4, workers: process.env.CI ? 2 : 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'blob' : 'html', reporter: process.env.CI ? 'blob' : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChartBarIcon } from '@heroicons/vue/20/solid'; import { ChartBarIcon, ArrowDownTrayIcon, EllipsisVerticalIcon, LockClosedIcon } from '@heroicons/vue/20/solid';
import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
@@ -8,17 +9,25 @@ import {
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
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 ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue'; import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
import { SecondaryButton } from '@/packages/ui/src';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { computed, type ComputedRef, inject, ref, watch } from 'vue'; import { computed, type ComputedRef, inject, ref, watch } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { import {
@@ -182,6 +191,28 @@ async function downloadExport(format: ExportFormat) {
const { projects } = useProjectsQuery(); const { projects } = useProjectsQuery();
const showExportModal = ref(false); const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
const showCreateReportModal = ref(false);
const showPremiumModal = ref(false);
const exportLoading = ref(false);
function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
showPremiumModal.value = true;
return;
}
exportLoading.value = true;
downloadExport(format).finally(() => {
exportLoading.value = false;
});
}
function onSaveReportClick() {
if (isAllowedToPerformPremiumAction()) {
showCreateReportModal.value = true;
} else {
showPremiumModal.value = true;
}
}
const groupedPieChartData = computed(() => { const groupedPieChartData = computed(() => {
return ( return (
@@ -240,16 +271,90 @@ const tableData = computed(() => {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<ReportCreateModal
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal">
This feature is only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center"> class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar> <ReportingTabNavbar
active="reporting"
class="hidden sm:flex"></ReportingTabNavbar>
</div> </div>
<div class="flex space-x-2"> <div class="hidden sm:flex space-x-2">
<ReportingExportButton :download="downloadExport"></ReportingExportButton> <DropdownMenu>
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton> <DropdownMenuTrigger as-child>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
Export
</SecondaryButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SecondaryButton
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick">
Save Report
</SecondaryButton>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger as-child class="sm:hidden">
<button
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
aria-label="More options">
<EllipsisVerticalIcon class="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
<DropdownMenuItem
v-if="canCreateReports()"
@click="onSaveReportClick">
Save Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</MainContainer> </MainContainer>
<ReportingFilterBar <ReportingFilterBar
v-model:selected-members="selectedMembers" v-model:selected-members="selectedMembers"

View File

@@ -16,7 +16,7 @@ const props = defineProps<{
:icon="icon" :icon="icon"
:class=" :class="
twMerge( twMerge(
'rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border', 'rounded-md px-2 sm:px-3 border py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border',
props.class props.class
) )
"> ">

View File

@@ -85,11 +85,11 @@ window.addEventListener('dashboard:refresh', () => {
filteredLatestTasks.length === 4 ? 'last:border-0' : '' filteredLatestTasks.length === 4 ? 'last:border-0' : ''
"></RecentlyTrackedTasksCardEntry> "></RecentlyTrackedTasksCardEntry>
</div> </div>
<div v-else class="text-center flex flex-1 justify-center items-center"> <div v-else class="text-center flex flex-1 justify-center items-center py-5">
<div> <div>
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon> <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3> <h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
<p class="pb-5 text-sm">Start tracking your time!</p> <p class="text-sm">Start tracking your time!</p>
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>

View File

@@ -44,6 +44,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
<div v-else class="text-center text-gray-500 py-8">No team activity found</div> <div v-else class="text-center text-gray-500 py-8">No team activity found</div>
<div <div
v-if="latestTeamActivity && latestTeamActivity.length <= 1" v-if="latestTeamActivity && latestTeamActivity.length <= 1"
:class="latestTeamActivity?.length === 1 ? 'pb-5' : 'py-5'"
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center">
<div> <div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon> <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
data-slot="input" data-slot="input"
:class=" :class="
cn( cn(
'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus:border-input-border focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-base sm:text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus:border-input-border focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class props.class
) )
" /> " />

View File

@@ -9,7 +9,17 @@ import {
ChevronRightIcon, ChevronRightIcon,
ChevronDoubleRightIcon, ChevronDoubleRightIcon,
ClockIcon, ClockIcon,
EllipsisVerticalIcon,
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { SecondaryButton } from '@/packages/ui/src';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -46,7 +56,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
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 UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -160,6 +170,19 @@ const selectedTimeEntries = ref<TimeEntry[]>([]);
const showExportModal = ref(false); const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
const showPremiumModal = ref(false);
const exportLoading = ref(false);
function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
showPremiumModal.value = true;
return;
}
exportLoading.value = true;
downloadExport(format).finally(() => {
exportLoading.value = false;
});
}
async function createTag(name: string) { async function createTag(name: string) {
return await useTagsStore().createTag(name); return await useTagsStore().createTag(name);
@@ -236,13 +259,76 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center"> class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="detailed"></ReportingTabNavbar> <ReportingTabNavbar
active="detailed"
class="hidden sm:flex"></ReportingTabNavbar>
</div> </div>
<ReportingExportButton :download="downloadExport"></ReportingExportButton> <div class="hidden sm:block">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
Export
</SecondaryButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child class="sm:hidden">
<button
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
aria-label="More options">
<EllipsisVerticalIcon class="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
</MainContainer> </MainContainer>
<ReportingFilterBar <ReportingFilterBar

View File

@@ -69,12 +69,17 @@ watch(currentPage, () => {
<template> <template>
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden"> <AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
<MainContainer <MainContainer
class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center"> class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared"></ReportingTabNavbar> <ReportingTabNavbar
active="shared"
class="hidden sm:flex"></ReportingTabNavbar>
</div> </div>
</MainContainer> </MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
</MainContainer>
<div v-if="!isAllowedToPerformPremiumAction()"> <div v-if="!isAllowedToPerformPremiumAction()">
<div class="py-12"> <div class="py-12">

View File

@@ -21,7 +21,7 @@ const props = withDefaults(
<button <button
:type="type" :type="type"
:disabled="loading" :disabled="loading"
class="inline-flex items-center px-3 py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150"> class="inline-flex items-center h-9 px-3 text-sm bg-button-primary-background border border-button-primary-border rounded-md font-medium text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')"> <span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner> <LoadingSpinner v-if="loading"></LoadingSpinner>
<component <component

View File

@@ -22,7 +22,7 @@ const props = withDefaults(
const sizeClasses = { const sizeClasses = {
small: 'text-xs px-2.5 py-1.5', small: 'text-xs px-2.5 py-1.5',
base: 'text-xs sm:text-sm px-3 py-2', base: 'h-9 px-3 text-sm',
}; };
</script> </script>

View File

@@ -25,7 +25,7 @@ const model = defineModel();
v-model="model" v-model="model"
:class=" :class="
twMerge( twMerge(
'h-9 px-3 py-1 text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm', 'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
props.class props.class
) )
" "

View File

@@ -262,7 +262,7 @@ useSelectEvents(
:enable-estimated-time="enableEstimatedTime" :enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown> @changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4"> <div class="flex items-center space-x-1 @2xl:space-x-2 px-2 @2xl:px-4 shrink-0">
<TimeTrackerTagDropdown <TimeTrackerTagDropdown
v-model="currentTimeEntry.tags" v-model="currentTimeEntry.tags"
:create-tag :create-tag

View File

@@ -498,16 +498,16 @@ const showCreateProject = ref(false);
</script> </script>
<template> <template>
<div v-if="projects.length === 0 && canCreateProject"> <template v-if="projects.length === 0 && canCreateProject">
<Button <Button
:variant="props.variant" :variant="props.variant"
:size="props.size" :size="props.size"
:class="twMerge('w-full justify-start', props.class)" :class="twMerge('w-full justify-start', props.class)"
@click="showCreateProject = true"> @click="showCreateProject = true">
<PlusIcon class="w-4" /> <PlusIcon class="w-4" />
<span>Add new project</span> <span class="truncate">Add new project</span>
</Button> </Button>
</div> </template>
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align"> <Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger> <template #trigger>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">