mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-13 12:52:41 +01:00
improve reporting page responsive layout; standardize button sizing;
prevent mobile input zoom; increase CI playwright shards
This commit is contained in:
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user