mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 20:52:14 +01:00
Compare commits
41 Commits
v0.11.1
...
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
shardIndex: [1, 2, 3, 4]
|
||||||
shardTotal: [8]
|
shardTotal: [4]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mailpit:
|
mailpit:
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
$placeholderUser->current_team_id = $member->organization_id;
|
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
|
|||||||
@@ -31,17 +31,12 @@ class TimeEntryService
|
|||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
}
|
}
|
||||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
|
||||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
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) {
|
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||||
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||||
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
|
||||||
'THEN '.$end.' '.
|
|
||||||
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
|
||||||
'END';
|
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
} 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) {
|
async function assertTimerIsRunning(page: Page) {
|
||||||
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, {
|
||||||
/bg-red-400\/80/,
|
timeout: 10000,
|
||||||
{
|
});
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertTimerIsStopped(page: Page) {
|
async function assertTimerIsStopped(page: Page) {
|
||||||
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-accent-300\/70/, {
|
||||||
/bg-accent-300\/70/,
|
timeout: 10000,
|
||||||
{
|
});
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Command Palette', () => {
|
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)
|
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
|
||||||
await expect(page.getByTestId('time_entry_description')).toBeVisible();
|
await expect(page.getByTestId('time_entry_description')).toBeVisible();
|
||||||
await expect(
|
await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
|
||||||
page
|
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(page.locator(':visible'))
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Dashboard cards
|
// Dashboard cards
|
||||||
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
||||||
@@ -109,10 +104,7 @@ test.describe('Employee Dashboard Restrictions', () => {
|
|||||||
|
|
||||||
// Timer should be available
|
// Timer should be available
|
||||||
await expect(
|
await expect(
|
||||||
employee.page
|
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(employee.page.locator(':visible'))
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
|
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 employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
await expect(
|
await expect(
|
||||||
employee.page
|
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(employee.page.locator(':visible'))
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Employee's time entry IS visible
|
// 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)
|
// Timer should still be running (the timer button should be red/active)
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(page.locator(':visible'))
|
|
||||||
).toHaveClass(/bg-red-400\/80/);
|
).toHaveClass(/bg-red-400\/80/);
|
||||||
|
|
||||||
// Stop the timer
|
// Stop the timer
|
||||||
|
|||||||
@@ -2,19 +2,12 @@ import { expect } from '@playwright/test';
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
export async function startOrStopTimerWithButton(page: Page) {
|
export async function startOrStopTimerWithButton(page: Page) {
|
||||||
await page
|
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(page.locator(':visible'))
|
|
||||||
.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertThatTimerHasStarted(page: Page) {
|
export async function assertThatTimerHasStarted(page: Page) {
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(page.locator(':visible'))
|
|
||||||
).toHaveClass(/bg-red-400\/80/);
|
).toHaveClass(/bg-red-400\/80/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +34,7 @@ export function newTimeEntryResponse(
|
|||||||
|
|
||||||
export async function assertThatTimerIsStopped(page: Page) {
|
export async function assertThatTimerIsStopped(page: Page) {
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||||
.getByTestId('dashboard_timer')
|
|
||||||
.getByTestId('timer_button')
|
|
||||||
.and(page.locator(':visible'))
|
|
||||||
).toHaveClass(/bg-accent-300\/70/);
|
).toHaveClass(/bg-accent-300\/70/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
|||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
/* Run tests in parallel */
|
/* Run tests in parallel */
|
||||||
workers: process.env.CI ? 2 : 4,
|
workers: 4,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: process.env.CI ? 'blob' : 'html',
|
reporter: process.env.CI ? 'blob' : 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Client } from '@/packages/api/src';
|
import type { Client } from '@/packages/api/src';
|
||||||
import { computed, ref } from 'vue';
|
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 { useClientsStore } from '@/utils/useClients';
|
||||||
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
|
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
@@ -46,15 +46,9 @@ const showEditModal = ref(false);
|
|||||||
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
||||||
<template v-if="client.is_archived">
|
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<span>Active</span>
|
||||||
<span>Archived</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
|
||||||
<span>Active</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<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">
|
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">
|
<script setup lang="ts">
|
||||||
import {
|
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
||||||
ChartBarIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
EllipsisVerticalIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
} from '@heroicons/vue/20/solid';
|
|
||||||
import { SaveIcon } from 'lucide-vue-next';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
@@ -14,25 +8,17 @@ import {
|
|||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
|
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
|
||||||
import { SecondaryButton } from '@/packages/ui/src';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
|
||||||
import { canCreateReports } from '@/utils/permissions';
|
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
||||||
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
||||||
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||||
import {
|
import {
|
||||||
@@ -196,28 +182,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
const { projects } = useProjectsQuery();
|
const { projects } = useProjectsQuery();
|
||||||
const showExportModal = ref(false);
|
const showExportModal = ref(false);
|
||||||
const exportUrl = ref<string | null>(null);
|
const exportUrl = ref<string | null>(null);
|
||||||
const showCreateReportModal = ref(false);
|
|
||||||
const showPremiumModal = ref(false);
|
|
||||||
const exportLoading = ref(false);
|
|
||||||
|
|
||||||
function triggerExport(format: ExportFormat) {
|
|
||||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
|
||||||
showPremiumModal.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
exportLoading.value = true;
|
|
||||||
downloadExport(format).finally(() => {
|
|
||||||
exportLoading.value = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSaveReportClick() {
|
|
||||||
if (isAllowedToPerformPremiumAction()) {
|
|
||||||
showCreateReportModal.value = true;
|
|
||||||
} else {
|
|
||||||
showPremiumModal.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedPieChartData = computed(() => {
|
const groupedPieChartData = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -276,79 +240,16 @@ const tableData = computed(() => {
|
|||||||
<ReportingExportModal
|
<ReportingExportModal
|
||||||
v-model:show="showExportModal"
|
v-model:show="showExportModal"
|
||||||
:export-url="exportUrl"></ReportingExportModal>
|
:export-url="exportUrl"></ReportingExportModal>
|
||||||
<ReportCreateModal
|
|
||||||
v-model:show="showCreateReportModal"
|
|
||||||
:properties="reportProperties"></ReportCreateModal>
|
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
|
||||||
This feature is only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="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">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
||||||
<ReportingTabNavbar active="reporting" class="hidden sm:flex"></ReportingTabNavbar>
|
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<DropdownMenu>
|
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
|
||||||
<DropdownMenuTrigger as-child>
|
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
|
||||||
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
|
|
||||||
Export
|
|
||||||
</SecondaryButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span>Export as PDF</span>
|
|
||||||
<LockClosedIcon
|
|
||||||
v-if="!isAllowedToPerformPremiumAction()"
|
|
||||||
class="w-3.5 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
||||||
Export as Excel
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('csv')">
|
|
||||||
Export as CSV
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('ods')">
|
|
||||||
Export as ODS
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick">
|
|
||||||
Save Report
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child class="sm:hidden">
|
|
||||||
<button
|
|
||||||
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
|
|
||||||
aria-label="More options">
|
|
||||||
<EllipsisVerticalIcon class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span>Export as PDF</span>
|
|
||||||
<LockClosedIcon
|
|
||||||
v-if="!isAllowedToPerformPremiumAction()"
|
|
||||||
class="w-3.5 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
||||||
Export as Excel
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('csv')"> Export as CSV </DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('ods')"> Export as ODS </DropdownMenuItem>
|
|
||||||
<DropdownMenuItem v-if="canCreateReports()" @click="onSaveReportClick">
|
|
||||||
Save Report
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</MainContainer>
|
|
||||||
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
|
|
||||||
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<ReportingFilterBar
|
<ReportingFilterBar
|
||||||
v-model:selected-members="selectedMembers"
|
v-model:selected-members="selectedMembers"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const props = defineProps<{
|
|||||||
:icon="icon"
|
:icon="icon"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -106,29 +106,15 @@ const option = computed(() => {
|
|||||||
return {
|
return {
|
||||||
tooltip: {},
|
tooltip: {},
|
||||||
visualMap: {
|
visualMap: {
|
||||||
|
min: 0,
|
||||||
|
max: max.value,
|
||||||
type: 'piecewise',
|
type: 'piecewise',
|
||||||
orient: 'horizontal',
|
orient: 'horizontal',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: 'center',
|
top: 'center',
|
||||||
pieces: [
|
inRange: {
|
||||||
{ value: 0, color: chartEmptyColor.value },
|
color: [chartEmptyColor.value, chartColor.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 },
|
|
||||||
],
|
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ window.addEventListener('dashboard:refresh', () => {
|
|||||||
filteredLatestTasks.length === 4 ? 'last:border-0' : ''
|
filteredLatestTasks.length === 4 ? 'last:border-0' : ''
|
||||||
"></RecentlyTrackedTasksCardEntry>
|
"></RecentlyTrackedTasksCardEntry>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-center flex flex-1 justify-center items-center py-5">
|
<div v-else class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
||||||
<p class="text-sm">Start tracking your time!</p>
|
<p class="pb-5 text-sm">Start tracking your time!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</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-else class="text-center text-gray-500 py-8">No team activity found</div>
|
||||||
<div
|
<div
|
||||||
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
|
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
|
||||||
:class="latestTeamActivity?.length === 1 ? 'pb-5' : 'py-5'"
|
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
<DialogContent
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const props = defineProps<{
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-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
|
props.class
|
||||||
)
|
)
|
||||||
" />
|
" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Banner from '@/Components/Banner.vue';
|
|||||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
import {
|
import {
|
||||||
|
Bars3Icon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
@@ -18,11 +19,10 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import { PanelLeft } from 'lucide-vue-next';
|
|
||||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.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 NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||||
import { initializeStores } from '@/utils/init';
|
import { initializeStores } from '@/utils/init';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
@@ -61,24 +61,6 @@ defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showSidebarMenu = ref(false);
|
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 isUnloading = ref(false);
|
||||||
|
|
||||||
const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
||||||
@@ -120,23 +102,11 @@ const page = usePage<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
<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
|
<div
|
||||||
:class="[
|
:class="{
|
||||||
sidebarVisible
|
'!flex bg-default-background w-full z-30': showSidebarMenu,
|
||||||
? 'max-lg:translate-x-0 max-lg:shadow-xl'
|
}"
|
||||||
: 'max-lg:-translate-x-full',
|
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">
|
||||||
]"
|
|
||||||
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">
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div
|
<div
|
||||||
class="border-b border-default-background-separator pb-2 flex items-center gap-1">
|
class="border-b border-default-background-separator pb-2 flex items-center gap-1">
|
||||||
@@ -151,13 +121,9 @@ const page = usePage<{
|
|||||||
@click="openPalette">
|
@click="openPalette">
|
||||||
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
|
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<XMarkIcon
|
||||||
variant="ghost"
|
class="w-8 lg:hidden flex-shrink-0"
|
||||||
size="icon"
|
@click="showSidebarMenu = false"></XMarkIcon>
|
||||||
class="h-7 w-7 flex-shrink-0 lg:hidden"
|
|
||||||
@click="closeSidebar">
|
|
||||||
<XMarkIcon class="h-4 w-4 text-icon-default" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-default-background-separator">
|
<div class="border-b border-default-background-separator">
|
||||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
<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">
|
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
||||||
<div
|
<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">
|
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
|
<Bars3Icon
|
||||||
variant="ghost"
|
class="w-7 text-text-secondary"
|
||||||
size="icon"
|
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||||
class="h-7 w-7 shrink-0"
|
|
||||||
@click="openSidebar">
|
|
||||||
<PanelLeft class="h-4 w-4 text-icon-default" />
|
|
||||||
</Button>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<OrganizationSwitcher></OrganizationSwitcher>
|
<OrganizationSwitcher></OrganizationSwitcher>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.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 SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
@@ -84,11 +84,9 @@ const shownTasks = computed(() => {
|
|||||||
<ol role="list" class="flex items-center space-x-2">
|
<ol role="list" class="flex items-center space-x-2">
|
||||||
<li>
|
<li>
|
||||||
<div class="flex items-center space-x-6">
|
<div class="flex items-center space-x-6">
|
||||||
<Link
|
<Link :href="route('projects')" class="flex items-center space-x-2.5">
|
||||||
:href="route('projects')"
|
<FolderIcon class="w-6 text-icon-default"></FolderIcon>
|
||||||
class="flex items-center space-x-2 sm:space-x-2.5">
|
<span class="font-medium">Projects</span>
|
||||||
<FolderIcon class="w-5 text-icon-default"></FolderIcon>
|
|
||||||
<span class="text-sm sm:text-base font-medium">Projects</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.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 SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -9,17 +9,7 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ChevronDoubleRightIcon,
|
ChevronDoubleRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
EllipsisVerticalIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
import { SecondaryButton } from '@/packages/ui/src';
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -56,7 +46,7 @@ import {
|
|||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
@@ -170,19 +160,6 @@ const selectedTimeEntries = ref<TimeEntry[]>([]);
|
|||||||
|
|
||||||
const showExportModal = ref(false);
|
const showExportModal = ref(false);
|
||||||
const exportUrl = ref<string | null>(null);
|
const exportUrl = ref<string | null>(null);
|
||||||
const showPremiumModal = ref(false);
|
|
||||||
const exportLoading = ref(false);
|
|
||||||
|
|
||||||
function triggerExport(format: ExportFormat) {
|
|
||||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
|
||||||
showPremiumModal.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
exportLoading.value = true;
|
|
||||||
downloadExport(format).finally(() => {
|
|
||||||
exportLoading.value = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTag(name: string) {
|
async function createTag(name: string) {
|
||||||
return await useTagsStore().createTag(name);
|
return await useTagsStore().createTag(name);
|
||||||
@@ -259,74 +236,13 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
<ReportingExportModal
|
<ReportingExportModal
|
||||||
v-model:show="showExportModal"
|
v-model:show="showExportModal"
|
||||||
:export-url="exportUrl"></ReportingExportModal>
|
:export-url="exportUrl"></ReportingExportModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
|
||||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="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">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
||||||
<ReportingTabNavbar active="detailed" class="hidden sm:flex"></ReportingTabNavbar>
|
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:block">
|
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
|
|
||||||
Export
|
|
||||||
</SecondaryButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span>Export as PDF</span>
|
|
||||||
<LockClosedIcon
|
|
||||||
v-if="!isAllowedToPerformPremiumAction()"
|
|
||||||
class="w-3.5 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
||||||
Export as Excel
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('csv')">
|
|
||||||
Export as CSV
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('ods')">
|
|
||||||
Export as ODS
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child class="sm:hidden">
|
|
||||||
<button
|
|
||||||
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
|
|
||||||
aria-label="More options">
|
|
||||||
<EllipsisVerticalIcon class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span>Export as PDF</span>
|
|
||||||
<LockClosedIcon
|
|
||||||
v-if="!isAllowedToPerformPremiumAction()"
|
|
||||||
class="w-3.5 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
||||||
Export as Excel
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('csv')">
|
|
||||||
Export as CSV
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="triggerExport('ods')">
|
|
||||||
Export as ODS
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</MainContainer>
|
|
||||||
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
|
|
||||||
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
<ReportingFilterBar
|
<ReportingFilterBar
|
||||||
|
|||||||
@@ -69,15 +69,12 @@ watch(currentPage, () => {
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
|
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="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">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
||||||
<ReportingTabNavbar active="shared" class="hidden sm:flex"></ReportingTabNavbar>
|
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
|
|
||||||
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
|
|
||||||
</MainContainer>
|
|
||||||
|
|
||||||
<div v-if="!isAllowedToPerformPremiumAction()">
|
<div v-if="!isAllowedToPerformPremiumAction()">
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ import { useClientsStore } from '@/utils/useClients';
|
|||||||
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useTimeEntriesInfiniteQuery();
|
||||||
useTimeEntriesInfiniteQuery();
|
|
||||||
const {
|
const {
|
||||||
createTimeEntry: createTimeEntryMutation,
|
createTimeEntry: createTimeEntryMutation,
|
||||||
updateTimeEntry,
|
updateTimeEntry,
|
||||||
@@ -146,10 +145,7 @@ function deleteSelected() {
|
|||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries="timeEntries"
|
:time-entries="timeEntries"
|
||||||
:tags="tags"></TimeEntryGroupedTable>
|
:tags="tags"></TimeEntryGroupedTable>
|
||||||
<div v-if="isPending" class="flex justify-center items-center py-12">
|
<div v-if="timeEntries.length === 0" class="text-center pt-12">
|
||||||
<LoadingSpinner></LoadingSpinner>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="timeEntries.length === 0" class="text-center pt-12">
|
|
||||||
<ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon>
|
<ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No time entries found</h3>
|
<h3 class="text-text-primary font-semibold">No time entries found</h3>
|
||||||
<p class="pb-5">Create your first time entry now!</p>
|
<p class="pb-5">Create your first time entry now!</p>
|
||||||
@@ -162,7 +158,7 @@ function deleteSelected() {
|
|||||||
<span> Loading more time entries... </span>
|
<span> Loading more time entries... </span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
class="flex justify-center items-center py-5 text-sm text-text-tertiary">
|
||||||
All time entries are loaded!
|
All time entries are loaded!
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const props = withDefaults(
|
|||||||
<button
|
<button
|
||||||
:type="type"
|
:type="type"
|
||||||
:disabled="loading"
|
: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' : '')">
|
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
|
||||||
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
||||||
<component
|
<component
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
small: 'text-xs px-2.5 py-1.5',
|
small: 'text-xs px-2.5 py-1.5',
|
||||||
base: 'h-9 px-3 text-sm',
|
base: 'text-xs sm:text-sm px-3 py-2',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
totalSeconds?: number;
|
totalMinutes?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
||||||
|
|
||||||
// Injected organization for formatting settings
|
// Injected organization for formatting settings
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||||
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -179,20 +179,20 @@ const dailyTotals = computed(() => {
|
|||||||
const totals: Record<string, number> = {};
|
const totals: Record<string, number> = {};
|
||||||
props.timeEntries.forEach((entry) => {
|
props.timeEntries.forEach((entry) => {
|
||||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||||
let durationSeconds: number;
|
let duration: number;
|
||||||
|
|
||||||
if (entry.end !== null) {
|
if (entry.end !== null) {
|
||||||
// Completed entry
|
// Completed entry
|
||||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
duration = getDayJsInstance()(entry.end).diff(
|
||||||
getDayJsInstance()(entry.start),
|
getDayJsInstance()(entry.start),
|
||||||
'seconds'
|
'minutes'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Running entry - use current time
|
// 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;
|
return totals;
|
||||||
});
|
});
|
||||||
@@ -444,7 +444,7 @@ onUnmounted(() => {
|
|||||||
:date="
|
:date="
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
||||||
"
|
"
|
||||||
:total-seconds="
|
:total-minutes="
|
||||||
dailyTotals[
|
dailyTotals[
|
||||||
getDayJsInstance()(arg.date)
|
getDayJsInstance()(arg.date)
|
||||||
.utc()
|
.utc()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const model = defineModel();
|
|||||||
v-model="model"
|
v-model="model"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const showMassUpdateModal = ref(false);
|
|||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="selectAll"
|
id="selectAll"
|
||||||
:checked="allSelected && selectedTimeEntries.length > 0"
|
:checked="allSelected"
|
||||||
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
|
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
|
|||||||
@@ -208,22 +208,17 @@ useSelectEvents(
|
|||||||
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
|
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
|
<input
|
||||||
ref="currentTimeEntryDescriptionInput"
|
ref="currentTimeEntryDescriptionInput"
|
||||||
v-model="tempDescription"
|
v-model="tempDescription"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
data-testid="time_entry_description"
|
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"
|
type="text"
|
||||||
@keydown.enter="startTimerIfNotActive"
|
@keydown.enter="startTimerIfNotActive"
|
||||||
@keydown.esc="showDropdown = false"
|
@keydown.esc="showDropdown = false"
|
||||||
@blur="updateTimeEntryDescription" />
|
@blur="updateTimeEntryDescription" />
|
||||||
<div class="@2xl:hidden pr-3 shrink-0">
|
|
||||||
<TimeTrackerStartStop
|
|
||||||
:active="isActive"
|
|
||||||
@changed="onToggleButtonPress"></TimeTrackerStartStop>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
|
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
|
||||||
ref="floating"
|
ref="floating"
|
||||||
@@ -267,7 +262,7 @@ useSelectEvents(
|
|||||||
:enable-estimated-time="enableEstimatedTime"
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
|
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center 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
|
<TimeTrackerTagDropdown
|
||||||
v-model="currentTimeEntry.tags"
|
v-model="currentTimeEntry.tags"
|
||||||
:create-tag
|
:create-tag
|
||||||
@@ -290,7 +285,7 @@ useSelectEvents(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<TimeTrackerStartStop
|
||||||
:active="isActive"
|
:active="isActive"
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
@@ -498,16 +498,16 @@ const showCreateProject = ref(false);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="projects.length === 0 && canCreateProject">
|
<div v-if="projects.length === 0 && canCreateProject">
|
||||||
<Button
|
<Button
|
||||||
:variant="props.variant"
|
:variant="props.variant"
|
||||||
:size="props.size"
|
:size="props.size"
|
||||||
:class="twMerge('w-full justify-start', props.class)"
|
:class="twMerge('w-full justify-start', props.class)"
|
||||||
@click="showCreateProject = true">
|
@click="showCreateProject = true">
|
||||||
<PlusIcon class="w-4" />
|
<PlusIcon class="w-4" />
|
||||||
<span class="truncate">Add new project</span>
|
<span>Add new project</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</div>
|
||||||
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
|
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -26,22 +26,22 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TagDropdown
|
<TagDropdown v-model="model" :create-tag :tags="tags" @changed="emit('changed')">
|
||||||
v-model="model"
|
|
||||||
:create-tag
|
|
||||||
:tags="tags"
|
|
||||||
:show-no-tag-option="false"
|
|
||||||
@changed="emit('changed')">
|
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
data-testid="tag_dropdown"
|
data-testid="tag_dropdown"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
iconColorClasses,
|
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>
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</TagDropdown>
|
</TagDropdown>
|
||||||
|
|||||||
@@ -79,10 +79,6 @@ function prefetchDashboard(queryClient: QueryClient) {
|
|||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
if (!organizationId) return;
|
if (!organizationId) return;
|
||||||
|
|
||||||
// Prefetch projects and tasks for RecentlyTrackedTasksCard
|
|
||||||
prefetchProjects(queryClient);
|
|
||||||
prefetchTasks(queryClient);
|
|
||||||
|
|
||||||
// Prefetch all dashboard card data
|
// Prefetch all dashboard card data
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ['timeEntries', organizationId],
|
queryKey: ['timeEntries', organizationId],
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
|
|||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
|
|
||||||
export function switchOrganization(organizationId: string) {
|
export function switchOrganization(organizationId: string) {
|
||||||
// Clear Inertia's prefetch cache to prevent stale pages from the old
|
|
||||||
// organization being served when navigating after the switch.
|
|
||||||
router.flushAll();
|
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
route('current-team.update'),
|
route('current-team.update'),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export function useTimeEntriesInfiniteQuery() {
|
|||||||
const queries: Record<string, string | undefined> = {
|
const queries: Record<string, string | undefined> = {
|
||||||
only_full_dates: 'true',
|
only_full_dates: 'true',
|
||||||
member_id: memberId.value,
|
member_id: memberId.value,
|
||||||
limit: '50',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pageParam) {
|
if (pageParam) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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>
|
<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
|
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -382,45 +382,4 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
|||||||
1
|
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);
|
$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