Compare commits

..

41 Commits

Author SHA1 Message Date
Gregor Vostrak
3c9159f2d4 Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
2026-02-11 17:11:05 +01:00
Gregor Vostrak
abfa7cea0d improve format settings e2e test consistency; improve euro icon sizing
consistency
2026-02-10 17:51:37 +01:00
Gregor Vostrak
bfc33b48c1 make sure that 404 current time entry requests do not override local
state while preparing new time entry
2026-02-10 17:21:18 +01:00
Gregor Vostrak
dc98151d42 responsive time entry modal fixes 2026-02-10 17:18:49 +01:00
Gregor Vostrak
e038870bc4 fix reporting tab selectors in e2e test 2026-02-10 15:20:59 +01:00
Gregor Vostrak
6f8f46f375 use frankenphp in the playwright tests CI to handle parallel requests
better
2026-02-10 14:57:31 +01:00
Gregor Vostrak
6c319fafbc add e2e tests for employee restrictions 2026-02-10 14:41:04 +01:00
Gregor Vostrak
d06b0633d3 add sharding for e2e tests in CI 2026-02-10 13:21:05 +01:00
Gregor Vostrak
2c4af95ee3 Add Tag Edit Modal and UI 2026-02-10 13:19:30 +01:00
Gregor Vostrak
215957104f Add Euro Symbol as Billable Icon when EUR is the organization currency.
fixes #423
2026-02-10 12:47:27 +01:00
Gregor Vostrak
fd012e7c69 Add Field component system and migrate UI 2026-02-10 12:22:53 +01:00
Gregor Vostrak
1ecb332458 Expand e2e test coverage migrate to API-based data setup 2026-02-10 11:45:08 +01:00
Gregor Vostrak
bbe05ca0d8 improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
2026-02-06 14:35:52 +01:00
Gregor Vostrak
d2644112c5 Allow updating public_until on already-public reports 2026-02-05 15:48:06 +01:00
Gregor Vostrak
66681066bc migrate datepickers to shadcn, Fixes #877, #807 2026-02-05 15:02:01 +01:00
Gregor Vostrak
f82f5e780c fix desync of checkboxes on the reporting detailed page, fixes #892 2026-02-04 17:22:08 +01:00
Gregor Vostrak
22f3af2b79 Make sure that time entry billable status updates when project changes,
fixes #981
2026-02-04 17:07:46 +01:00
Gregor Vostrak
7d068fecae fix admin panel time entry save and update, fixes #997 2026-02-04 14:32:56 +01:00
Gregor Vostrak
9be97a8f84 Improve Time page responsiveness and compact tags, fixes #896 2026-02-03 19:21:57 +01:00
Gregor Vostrak
03e0377101 fix responsive issues in timetracker recently tracked entries dropdown 2026-02-03 14:30:56 +01:00
Gregor Vostrak
a58becc268 Add calendar query prefetch 2026-02-03 14:12:36 +01:00
Gregor Vostrak
09c3205680 Allow NONE filter value to shared reports and add shared-report tests 2026-02-02 20:42:07 +01:00
Gregor Vostrak
18989a9a8e Add Mailpit SMTP and refine Playwright tests 2026-02-02 16:06:56 +01:00
Gregor Vostrak
98634f4a19 fix Y-Label ui regression from echarts update 2026-02-02 14:55:09 +01:00
Gregor Vostrak
1597b5490a Enable npm workspaces and update dependencies 2026-02-02 03:20:45 +01:00
Gregor Vostrak
72662727c5 Add client_ids filter to time entry export 2026-02-02 01:57:40 +01:00
Gregor Vostrak
0d3978a55d Add reporting e2e helpers and detailed tests 2026-02-02 01:31:35 +01:00
Gregor Vostrak
fe8c7e9a7d Update openapi api client spec 2026-02-02 01:28:42 +01:00
Gregor Vostrak
66dfc511a9 add no project, no task, no client, no task, no tag support to the API 2026-02-02 01:16:28 +01:00
Gregor Vostrak
8524e01033 refactor: extract ReportingFilterBar and migrate reporting to TanStack Query 2026-02-02 01:03:00 +01:00
Gregor Vostrak
bca1e8b3b5 migrate select/multiselect components to Radix Vue primitives 2026-02-02 00:56:06 +01:00
Gregor Vostrak
44bcce97cf fix styling inconsistencies 2026-01-27 20:49:30 +01:00
Gregor Vostrak
99400ca655 fix: display custom billable rate correctly on project detail page 2026-01-27 20:11:35 +01:00
Gregor Vostrak
672c243c91 add command palette 2026-01-27 18:29:40 +01:00
Gregor Vostrak
3fb75ec3d5 add outline and secondary variants to TimeTrackerStartStop button to reduce visual complexity 2026-01-15 19:17:55 +01:00
Gregor Vostrak
79999fde28 remove redundant projects pinia store after tanstack query migration 2026-01-14 19:41:07 +01:00
Gregor Vostrak
b2a04c8de5 load time entries above pagination limit for calendar, fixes #995 2026-01-14 19:22:17 +01:00
Gregor Vostrak
900ee29a6f fix e2e project filtering in reporting e2e test 2026-01-14 18:58:28 +01:00
Gregor Vostrak
ebbc4e6837 use tanstack query in ProjectMultiselectDropdown, ClientTableRow and ProjectDropdown; fix e2e 2026-01-14 18:21:29 +01:00
Gregor Vostrak
6f68bbbd48 refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table 2026-01-14 17:01:45 +01:00
Gregor Vostrak
47c2d8e6de upgrade inertia v2; add prefetching; migrate queries to tanstack query
vue
2026-01-09 03:32:42 +01:00
38 changed files with 97 additions and 578 deletions

View File

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

View File

@@ -196,7 +196,6 @@ class MemberService
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->current_team_id = $member->organization_id;
$placeholderUser->save();
$member->user()->associate($placeholderUser);

View File

@@ -31,17 +31,12 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -31,21 +31,15 @@ async function selectCommand(page: Page, name: string) {
}
async function assertTimerIsRunning(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
/bg-red-400\/80/,
{
timeout: 10000,
}
);
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, {
timeout: 10000,
});
}
async function assertTimerIsStopped(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
/bg-accent-300\/70/,
{
timeout: 10000,
}
);
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-accent-300\/70/, {
timeout: 10000,
});
}
test.describe('Command Palette', () => {

View File

@@ -25,12 +25,7 @@ test('test that dashboard loads with all expected sections', async ({ page }) =>
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
await expect(page.getByTestId('time_entry_description')).toBeVisible();
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toBeVisible();
await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
// Dashboard cards
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
@@ -109,10 +104,7 @@ test.describe('Employee Dashboard Restrictions', () => {
// Timer should be available
await expect(
employee.page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(employee.page.locator(':visible'))
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
).toBeVisible();
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
});

View File

@@ -1450,10 +1450,7 @@ test.describe('Employee Time Entry Isolation', () => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(
employee.page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(employee.page.locator(':visible'))
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
).toBeVisible({ timeout: 10000 });
// Employee's time entry IS visible

View File

@@ -354,10 +354,7 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
// Timer should still be running (the timer button should be red/active)
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
).toHaveClass(/bg-red-400\/80/);
// Stop the timer

View File

@@ -2,19 +2,12 @@ import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) {
await page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
.click();
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
}
export async function assertThatTimerHasStarted(page: Page) {
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
).toHaveClass(/bg-red-400\/80/);
}
@@ -41,10 +34,7 @@ export function newTimeEntryResponse(
export async function assertThatTimerIsStopped(page: Page) {
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
).toHaveClass(/bg-accent-300\/70/);
}

View File

@@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Run tests in parallel */
workers: process.env.CI ? 2 : 4,
workers: 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,7 +1,7 @@
<script setup lang="ts">
import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -46,15 +46,9 @@ const showEditModal = ref(false);
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
ChartBarIcon,
ArrowDownTrayIcon,
EllipsisVerticalIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { SaveIcon } from 'lucide-vue-next';
import { ChartBarIcon } from '@heroicons/vue/20/solid';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatHumanReadableDuration,
@@ -14,25 +8,17 @@ 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 {
@@ -196,28 +182,6 @@ 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 (
@@ -276,79 +240,16 @@ 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="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
class="py-3 sm:py-5 border-b border-default-background-separator flex 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" class="hidden sm:flex"></ReportingTabNavbar>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<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 class="flex space-x-2">
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
</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 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',
'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',
props.class
)
">

View File

@@ -106,29 +106,15 @@ const option = computed(() => {
return {
tooltip: {},
visualMap: {
min: 0,
max: max.value,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
pieces: [
{ value: 0, color: chartEmptyColor.value },
{
gt: 0,
lte: max.value * 0.25,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.3).hex(),
},
{
gt: max.value * 0.25,
lte: max.value * 0.5,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.6).hex(),
},
{
gt: max.value * 0.5,
lte: max.value * 0.75,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.8).hex(),
},
{ gt: max.value * 0.75, lte: max.value, color: chartColor.value },
],
inRange: {
color: [chartEmptyColor.value, chartColor.value],
},
show: false,
},
calendar: {

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 py-5">
<div v-else class="text-center flex flex-1 justify-center items-center">
<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="text-sm">Start tracking your time!</p>
<p class="pb-5 text-sm">Start tracking your time!</p>
</div>
</div>
</DashboardCard>

View File

@@ -44,7 +44,6 @@ 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

@@ -31,14 +31,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<div
:class="
cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto'
)
">
<DialogContent
v-bind="forwarded"
:class="
cn(
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">

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-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',
'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',
props.class
)
" />

View File

@@ -4,6 +4,7 @@ import Banner from '@/Components/Banner.vue';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
Bars3Icon,
CalendarIcon,
ChartBarIcon,
ClockIcon,
@@ -18,11 +19,10 @@ import {
XMarkIcon,
DocumentTextIcon,
} from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { nextTick, onMounted, provide, ref } from 'vue';
import { onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -61,24 +61,6 @@ defineProps({
});
const showSidebarMenu = ref(false);
const sidebarVisible = ref(false);
function openSidebar() {
showSidebarMenu.value = true;
nextTick(() => {
requestAnimationFrame(() => {
sidebarVisible.value = true;
});
});
}
function closeSidebar() {
sidebarVisible.value = false;
setTimeout(() => {
showSidebarMenu.value = false;
}, 200);
}
const isUnloading = ref(false);
const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
@@ -120,23 +102,11 @@ const page = usePage<{
<template>
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<!-- Mobile sidebar overlay -->
<Teleport to="body">
<div v-if="showSidebarMenu" class="fixed inset-0 z-40 lg:hidden" @click="closeSidebar">
<div
class="absolute inset-0 bg-default-background transition-opacity duration-200"
:class="sidebarVisible ? 'opacity-50' : 'opacity-0'" />
</div>
</Teleport>
<div
:class="[
sidebarVisible
? 'max-lg:translate-x-0 max-lg:shadow-xl'
: 'max-lg:-translate-x-full',
]"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
:style="showSidebarMenu ? { display: 'flex' } : undefined">
:class="{
'!flex bg-default-background w-full z-30': showSidebarMenu,
}"
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
<div class="flex flex-col h-full">
<div
class="border-b border-default-background-separator pb-2 flex items-center gap-1">
@@ -151,13 +121,9 @@ const page = usePage<{
@click="openPalette">
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 flex-shrink-0 lg:hidden"
@click="closeSidebar">
<XMarkIcon class="h-4 w-4 text-icon-default" />
</Button>
<XMarkIcon
class="w-8 lg:hidden flex-shrink-0"
@click="showSidebarMenu = false"></XMarkIcon>
</div>
<div class="border-b border-default-background-separator">
<CurrentSidebarTimer></CurrentSidebarTimer>
@@ -313,13 +279,9 @@ const page = usePage<{
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
<div
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="openSidebar">
<PanelLeft class="h-4 w-4 text-icon-default" />
</Button>
<Bars3Icon
class="w-7 text-text-secondary"
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
<div class="flex items-center gap-1">
<OrganizationSwitcher></OrganizationSwitcher>
<Button

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, ref } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -84,11 +84,9 @@ const shownTasks = computed(() => {
<ol role="list" class="flex items-center space-x-2">
<li>
<div class="flex items-center space-x-6">
<Link
:href="route('projects')"
class="flex items-center space-x-2 sm:space-x-2.5">
<FolderIcon class="w-5 text-icon-default"></FolderIcon>
<span class="text-sm sm:text-base font-medium">Projects</span>
<Link :href="route('projects')" class="flex items-center space-x-2.5">
<FolderIcon class="w-6 text-icon-default"></FolderIcon>
<span class="font-medium">Projects</span>
</Link>
</div>
</li>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import { FolderIcon, PlusIcon } from '@heroicons/vue/24/outline';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import type {

View File

@@ -9,17 +9,7 @@ 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';
@@ -56,7 +46,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -170,19 +160,6 @@ 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);
@@ -259,74 +236,13 @@ 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="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
class="py-3 sm:py-5 border-b border-default-background-separator flex 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" class="hidden sm:flex"></ReportingTabNavbar>
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
</div>
<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>
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
</MainContainer>
<ReportingFilterBar

View File

@@ -69,15 +69,12 @@ watch(currentPage, () => {
<template>
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
<MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex 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" class="hidden sm:flex"></ReportingTabNavbar>
<ReportingTabNavbar active="shared"></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

@@ -32,8 +32,7 @@ import { useClientsStore } from '@/utils/useClients';
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
useTimeEntriesInfiniteQuery();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useTimeEntriesInfiniteQuery();
const {
createTimeEntry: createTimeEntryMutation,
updateTimeEntry,
@@ -146,10 +145,7 @@ function deleteSelected() {
:currency="getOrganizationCurrencyString()"
:time-entries="timeEntries"
:tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12">
<LoadingSpinner></LoadingSpinner>
</div>
<div v-else-if="timeEntries.length === 0" class="text-center pt-12">
<div v-if="timeEntries.length === 0" class="text-center pt-12">
<ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon>
<h3 class="text-text-primary font-semibold">No time entries found</h3>
<p class="pb-5">Create your first time entry now!</p>
@@ -162,7 +158,7 @@ function deleteSelected() {
<span> Loading more time entries... </span>
</div>
<div
v-else-if="!hasNextPage && timeEntries.length > 0"
v-else-if="!hasNextPage"
class="flex justify-center items-center py-5 text-sm text-text-tertiary">
All time entries are loaded!
</div>

View File

@@ -21,7 +21,7 @@ const props = withDefaults(
<button
:type="type"
:disabled="loading"
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">
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">
<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: 'h-9 px-3 text-sm',
base: 'text-xs sm:text-sm px-3 py-2',
};
</script>

View File

@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalSeconds?: number;
totalMinutes?: number;
}>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span>
</div>
</template>

View File

@@ -179,20 +179,20 @@ const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
let duration: number;
if (entry.end !== null) {
// Completed entry
durationSeconds = getDayJsInstance()(entry.end).diff(
duration = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
'minutes'
);
} else {
// Running entry - use current time
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
}
totals[date] = (totals[date] || 0) + durationSeconds;
totals[date] = (totals[date] || 0) + duration;
});
return totals;
});
@@ -444,7 +444,7 @@ onUnmounted(() => {
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-seconds="
:total-minutes="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()

View File

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

View File

@@ -69,7 +69,7 @@ const showMassUpdateModal = ref(false);
">
<Checkbox
id="selectAll"
:checked="allSelected && selectedTimeEntries.length > 0"
:checked="allSelected"
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
</Checkbox>
<FieldLabel

View File

@@ -208,22 +208,17 @@ useSelectEvents(
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center relative">
<div class="flex flex-1 items-center pr-6 relative">
<input
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />
<div class="@2xl:hidden pr-3 shrink-0">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
@@ -267,7 +262,7 @@ useSelectEvents(
:enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-1 @2xl:space-x-2 px-2 @2xl:px-4 shrink-0">
<div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4">
<TimeTrackerTagDropdown
v-model="currentTimeEntry.tags"
:create-tag
@@ -290,7 +285,7 @@ useSelectEvents(
</div>
</div>
</div>
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:block">
<div class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop
:active="isActive"
size="large"

View File

@@ -498,16 +498,16 @@ const showCreateProject = ref(false);
</script>
<template>
<template v-if="projects.length === 0 && canCreateProject">
<div 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 class="truncate">Add new project</span>
<span>Add new project</span>
</Button>
</template>
</div>
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger>
<div class="flex items-center gap-1">

View File

@@ -26,22 +26,22 @@ defineProps<{
</script>
<template>
<TagDropdown
v-model="model"
:create-tag
:tags="tags"
:show-no-tag-option="false"
@changed="emit('changed')">
<TagDropdown v-model="model" :create-tag :tags="tags" @changed="emit('changed')">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'relative flex-shrink-0 ring-0 focus:outline-none focus:ring-2 focus:ring-ring transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-10 h-10 flex items-center justify-center'
'flex-shrink-0 ring-0 focus:outline-none focus:ring-2 focus:ring-ring transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-10 h-10 flex items-center justify-center'
)
">
<TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></TagIcon>
<span
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</span>
</button>
</template>
</TagDropdown>

View File

@@ -79,10 +79,6 @@ function prefetchDashboard(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
// Prefetch projects and tasks for RecentlyTrackedTasksCard
prefetchProjects(queryClient);
prefetchTasks(queryClient);
// Prefetch all dashboard card data
queryClient.prefetchQuery({
queryKey: ['timeEntries', organizationId],

View File

@@ -21,7 +21,6 @@ export function useTimeEntriesInfiniteQuery() {
const queries: Record<string, string | undefined> = {
only_full_dates: 'true',
member_id: memberId.value,
limit: '50',
};
if (pageParam) {

View File

@@ -2,7 +2,7 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title>

View File

@@ -437,52 +437,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:06:00'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange

View File

@@ -382,45 +382,4 @@ class DeletionServiceTest extends TestCaseWithDatabase
1
);
}
public function test_delete_user_with_current_organization_set_to_owned_org_that_will_be_deleted_does_not_cause_foreign_key_violation(): void
{
// Arrange
// User A creates an organization and invites User B
$userA = User::factory()->create();
$userB = User::factory()->create();
$organizationOfA = Organization::factory()->withOwner($userA)->create();
$organizationOfB = Organization::factory()->withOwner($userB)->create();
Member::factory()->forUser($userA)->forOrganization($organizationOfA)->role(Role::Owner)->create();
Member::factory()->forUser($userB)->forOrganization($organizationOfB)->role(Role::Owner)->create();
$memberBInOrgA = Member::factory()->forUser($userB)->forOrganization($organizationOfA)->role(Role::Employee)->create();
TimeEntry::factory()->forOrganization($organizationOfA)->forMember($memberBInOrgA)->createMany(2);
// User B's current_organization_id points to their own org (the one that will be deleted)
$userB->update(['current_team_id' => $organizationOfB->getKey()]);
// Act
$this->deletionService->deleteUser($userB);
// Assert
$this->assertDatabaseMissing(User::class, [
'id' => $userB->getKey(),
]);
$this->assertDatabaseMissing(Organization::class, [
'id' => $organizationOfB->getKey(),
]);
$this->assertDatabaseHas(Organization::class, [
'id' => $organizationOfA->getKey(),
]);
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
$placeholderUser = User::query()->where('is_placeholder', true)->first();
$this->assertNotNull($placeholderUser);
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
$this->assertDatabaseHas(Member::class, [
'id' => $memberBInOrgA->getKey(),
'user_id' => $placeholderUser->getKey(),
'organization_id' => $organizationOfA->getKey(),
'role' => Role::Placeholder->value,
]);
}
}

View File

@@ -1205,101 +1205,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
];
$this->assertEqualsCanonicalizing($expected, $result);
}
/**
* Test that rounding up does NOT add extra time when the entry is already on a 15-minute boundary.
* f.e. 13:00 - 14:30 (90 minutes) should stay at 90 minutes when rounding up with 15-minute interval.
*/
public function test_aggregate_time_round_up_does_not_add_time_when_already_on_boundary(): void
{
// Arrange
// Create a time entry with duration exactly on a 15-minute boundary (90 minutes = 5400 seconds)
// This simulates 13:00 - 14:30 (or any 90-minute entry)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
5400 // 90 minutes = 1 hour 30 minutes, exactly on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// The entry is already on a 15-minute boundary (90 minutes), so it should stay at 90 minutes (5400 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 5400, // 90 minutes - should NOT be rounded to 105 minutes (6300 seconds)
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 5400, // 90 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
/**
* Test that rounding up works correctly for entries NOT on a boundary.
* Example: 13:00 - 13:48 (48 minutes) should round up to 13:00 - 14:00 (60 minutes).
*/
public function test_aggregate_time_round_up_works_when_not_on_boundary(): void
{
// Arrange
// Create a time entry with duration NOT on a 15-minute boundary (48 minutes = 2880 seconds)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
2880 // 48 minutes, not on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// 48 minutes rounded up to 15-minute interval = 60 minutes (3600 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
}