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:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
services:
mailpit:

View File

@@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Run tests in parallel */
workers: 4,
workers: process.env.CI ? 2 : 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'blob' : 'html',
/* 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">
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 {
formatHumanReadableDuration,
@@ -8,17 +9,25 @@ import {
} from '@/packages/ui/src/utils/time';
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 ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.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 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 { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import {
@@ -182,6 +191,28 @@ async function downloadExport(format: ExportFormat) {
const { projects } = useProjectsQuery();
const showExportModal = ref(false);
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(() => {
return (
@@ -240,16 +271,90 @@ const tableData = computed(() => {
<ReportingExportModal
v-model:show="showExportModal"
: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
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">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
<ReportingTabNavbar
active="reporting"
class="hidden sm:flex"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
<div class="hidden sm:flex space-x-2">
<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>
<SecondaryButton
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick">
Save Report
</SecondaryButton>
</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>
<ReportingFilterBar
v-model:selected-members="selectedMembers"

View File

@@ -16,7 +16,7 @@ const props = defineProps<{
:icon="icon"
:class="
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
)
">

View File

@@ -85,11 +85,11 @@ window.addEventListener('dashboard:refresh', () => {
filteredLatestTasks.length === 4 ? 'last:border-0' : ''
"></RecentlyTrackedTasksCardEntry>
</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>
<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>
<p class="pb-5 text-sm">Start tracking your time!</p>
<p class="text-sm">Start tracking your time!</p>
</div>
</div>
</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-if="latestTeamActivity && latestTeamActivity.length <= 1"
:class="latestTeamActivity?.length === 1 ? 'pb-5' : 'py-5'"
class="text-center flex flex-1 justify-center items-center">
<div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
data-slot="input"
:class="
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
)
" />

View File

@@ -9,7 +9,17 @@ import {
ChevronRightIcon,
ChevronDoubleRightIcon,
ClockIcon,
EllipsisVerticalIcon,
ArrowDownTrayIcon,
LockClosedIcon,
} 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 { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia';
@@ -46,7 +56,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
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 { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -160,6 +170,19 @@ const selectedTimeEntries = ref<TimeEntry[]>([]);
const showExportModal = ref(false);
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) {
return await useTagsStore().createTag(name);
@@ -236,13 +259,76 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<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">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
<ReportingTabNavbar
active="detailed"
class="hidden sm:flex"></ReportingTabNavbar>
</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>
<ReportingFilterBar

View File

@@ -69,12 +69,17 @@ watch(currentPage, () => {
<template>
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
<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">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
<ReportingTabNavbar
active="shared"
class="hidden sm:flex"></ReportingTabNavbar>
</div>
</MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
</MainContainer>
<div v-if="!isAllowedToPerformPremiumAction()">
<div class="py-12">

View File

@@ -21,7 +21,7 @@ const props = withDefaults(
<button
:type="type"
: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' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component

View File

@@ -22,7 +22,7 @@ const props = withDefaults(
const sizeClasses = {
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>

View File

@@ -25,7 +25,7 @@ const model = defineModel();
v-model="model"
:class="
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
)
"

View File

@@ -262,7 +262,7 @@ useSelectEvents(
:enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
</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
v-model="currentTimeEntry.tags"
:create-tag

View File

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