mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
41 Commits
v0.11.0
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c9159f2d4 | ||
|
|
abfa7cea0d | ||
|
|
bfc33b48c1 | ||
|
|
dc98151d42 | ||
|
|
e038870bc4 | ||
|
|
6f8f46f375 | ||
|
|
6c319fafbc | ||
|
|
d06b0633d3 | ||
|
|
2c4af95ee3 | ||
|
|
215957104f | ||
|
|
fd012e7c69 | ||
|
|
1ecb332458 | ||
|
|
bbe05ca0d8 | ||
|
|
d2644112c5 | ||
|
|
66681066bc | ||
|
|
f82f5e780c | ||
|
|
22f3af2b79 | ||
|
|
7d068fecae | ||
|
|
9be97a8f84 | ||
|
|
03e0377101 | ||
|
|
a58becc268 | ||
|
|
09c3205680 | ||
|
|
18989a9a8e | ||
|
|
98634f4a19 | ||
|
|
1597b5490a | ||
|
|
72662727c5 | ||
|
|
0d3978a55d | ||
|
|
fe8c7e9a7d | ||
|
|
66dfc511a9 | ||
|
|
8524e01033 | ||
|
|
bca1e8b3b5 | ||
|
|
44bcce97cf | ||
|
|
99400ca655 | ||
|
|
672c243c91 | ||
|
|
3fb75ec3d5 | ||
|
|
79999fde28 | ||
|
|
b2a04c8de5 | ||
|
|
900ee29a6f | ||
|
|
ebbc4e6837 | ||
|
|
6f68bbbd48 | ||
|
|
47c2d8e6de |
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, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user