mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
add project progress sorting and fix direction ui for number based
columns in the project table
This commit is contained in:
+161
-57
@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
createTaskViaApi,
|
||||
createClientViaApi,
|
||||
createTimeEntryViaApi,
|
||||
archiveProjectViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
|
||||
});
|
||||
|
||||
// Sorting tests
|
||||
test('test that sorting projects by name works', async ({ page }) => {
|
||||
test('test that sorting projects by all columns works', async ({ page, ctx }) => {
|
||||
// Seed projects with distinct values for each sortable column
|
||||
const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
|
||||
const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });
|
||||
|
||||
// Project A: client Alpha, low billable rate, has estimated time, active
|
||||
const projectA = await createProjectViaApi(ctx, {
|
||||
name: 'AAA Project',
|
||||
client_id: clientAlpha.id,
|
||||
is_billable: true,
|
||||
billable_rate: 5000,
|
||||
estimated_time: 36000, // 10h
|
||||
});
|
||||
// Add 1h of time entries (10% progress)
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '1h',
|
||||
projectId: projectA.id,
|
||||
});
|
||||
|
||||
// Project B: client Beta, high billable rate, has estimated time, archived
|
||||
const projectB = await createProjectViaApi(ctx, {
|
||||
name: 'BBB Project',
|
||||
client_id: clientBeta.id,
|
||||
is_billable: true,
|
||||
billable_rate: 15000,
|
||||
estimated_time: 7200, // 2h
|
||||
});
|
||||
// Add 1h of time entries (50% progress)
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '1h',
|
||||
projectId: projectB.id,
|
||||
});
|
||||
await archiveProjectViaApi(ctx, {
|
||||
...projectB,
|
||||
client_id: clientBeta.id,
|
||||
billable_rate: 15000,
|
||||
estimated_time: 7200,
|
||||
});
|
||||
|
||||
// Project C: no client, medium billable rate, no estimated time, active
|
||||
const projectC = await createProjectViaApi(ctx, {
|
||||
name: 'CCC Project',
|
||||
is_billable: true,
|
||||
billable_rate: 10000,
|
||||
});
|
||||
// Add 3h of time entries
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '3h',
|
||||
projectId: projectC.id,
|
||||
});
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
await expect(page.getByText('AAA Project')).toBeVisible();
|
||||
await expect(page.getByText('BBB Project')).toBeVisible();
|
||||
await expect(page.getByText('CCC Project')).toBeVisible();
|
||||
|
||||
// Get initial project names
|
||||
const getProjectNames = async () => {
|
||||
const rows = page
|
||||
.getByTestId('project_table')
|
||||
.locator('[data-testid="project_table"] > div')
|
||||
.filter({ hasNot: page.locator('.border-t') });
|
||||
const names: string[] = [];
|
||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
||||
const nameCell = row.locator('div').first();
|
||||
const text = await nameCell.textContent();
|
||||
if (text) {
|
||||
names.push(text.trim());
|
||||
}
|
||||
// Helper to get the visual order of our seeded projects by reading
|
||||
// all row text in a single evaluate call (avoids locator timing issues)
|
||||
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
|
||||
const getOrder = async (): Promise<string[]> => {
|
||||
const allRowTexts = await page.evaluate(() => {
|
||||
const table = document.querySelector('[data-testid="project_table"]');
|
||||
if (!table) return [];
|
||||
const rows = table.querySelectorAll('[role="row"]');
|
||||
return Array.from(rows).map((row) => row.textContent ?? '');
|
||||
});
|
||||
const order: string[] = [];
|
||||
for (const text of allRowTexts) {
|
||||
const match = seededNames.find((name) => text.includes(name));
|
||||
if (match) order.push(match);
|
||||
}
|
||||
return names;
|
||||
return order;
|
||||
};
|
||||
|
||||
// Click on Name header to sort ascending (default should already be ascending)
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
// Helper: click a column header and wait for sort to apply.
|
||||
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
|
||||
const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
|
||||
const header = page
|
||||
.locator('[data-testid="project_table"] .select-none', {
|
||||
hasText: headerText,
|
||||
})
|
||||
.first();
|
||||
await header.click();
|
||||
// Wait until the expected project appears before the others among our seeded set
|
||||
await page.waitForFunction(
|
||||
({ expected, names }) => {
|
||||
const table = document.querySelector('[data-testid="project_table"]');
|
||||
if (!table) return false;
|
||||
const rows = table.querySelectorAll('[role="row"]');
|
||||
let firstSeededIdx = -1;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const text = rows[i].textContent ?? '';
|
||||
if (names.some((n: string) => text.includes(n))) {
|
||||
firstSeededIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstSeededIdx === -1) return false;
|
||||
return (rows[firstSeededIdx].textContent ?? '').includes(expected);
|
||||
},
|
||||
{ expected: expectedFirstAmongSeeded, names: seededNames },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
};
|
||||
|
||||
// Wait for sort indicator to appear
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
// --- Sort by Name ---
|
||||
// Default is name asc (A-Z)
|
||||
let order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
|
||||
|
||||
// Click again to sort descending
|
||||
await nameHeader.click();
|
||||
// Click to toggle to Z-A
|
||||
await clickSortHeader('Name', 'CCC Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
|
||||
|
||||
// Verify the sort indicator is still visible (showing descending)
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
});
|
||||
// --- Sort by Client (text: first click = A-Z, no-client last) ---
|
||||
await clickSortHeader('Client', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
|
||||
|
||||
test('test that sorting projects by status works', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
// Reverse: Z-A, no-client still last
|
||||
await clickSortHeader('Client', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client
|
||||
|
||||
// Default is "all" so no filter needed - Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
// --- Sort by Total Time (numeric: first click = highest first) ---
|
||||
await clickSortHeader('Total Time', 'CCC Project');
|
||||
order = await getOrder();
|
||||
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
|
||||
|
||||
// Click on Status header to sort
|
||||
const statusHeader = page.getByText('Status').first();
|
||||
await statusHeader.click();
|
||||
// Reverse: lowest first
|
||||
await clickSortHeader('Total Time', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order[2]).toBe('CCC Project'); // C=3h last
|
||||
|
||||
// Sort indicator should be visible
|
||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||
// --- Sort by Billable Rate (numeric: first click = highest first) ---
|
||||
await clickSortHeader('Billable Rate', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000
|
||||
|
||||
// Reverse: lowest first
|
||||
await clickSortHeader('Billable Rate', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000
|
||||
|
||||
// --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
|
||||
await clickSortHeader('Progress', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate
|
||||
|
||||
// Reverse: lowest first, no-estimate still last
|
||||
await clickSortHeader('Progress', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate
|
||||
|
||||
// --- Sort by Status (first click = active first, archived last) ---
|
||||
await expect(async () => {
|
||||
await clickSortHeader('Status', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
|
||||
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
|
||||
}).toPass({ timeout: 5000 });
|
||||
|
||||
// Reverse: archived first
|
||||
await expect(async () => {
|
||||
await clickSortHeader('Status', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
|
||||
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// Filter tests
|
||||
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
|
||||
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
||||
});
|
||||
|
||||
// Create new project with new Client
|
||||
|
||||
// Create new project with existing Client
|
||||
|
||||
// Delete project via More Options
|
||||
|
||||
// Test that project task count is displayed correctly
|
||||
|
||||
// Edit Project Modal Test
|
||||
|
||||
// Add Project with billable rate
|
||||
|
||||
// Edit Project with billable rate
|
||||
|
||||
// Edit Project Member Billable Rate
|
||||
|
||||
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
|
||||
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
@@ -201,6 +201,37 @@ export async function createProjectViaApi(
|
||||
return body.data as { id: string; name: string; color: string; is_billable: boolean };
|
||||
}
|
||||
|
||||
export async function archiveProjectViaApi(
|
||||
ctx: TestContext,
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_billable: boolean;
|
||||
client_id?: string | null;
|
||||
billable_rate?: number | null;
|
||||
estimated_time?: number | null;
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,
|
||||
{
|
||||
data: {
|
||||
name: project.name,
|
||||
color: project.color,
|
||||
is_billable: project.is_billable,
|
||||
is_archived: true,
|
||||
client_id: project.client_id ?? null,
|
||||
billable_rate: project.billable_rate ?? null,
|
||||
estimated_time: project.estimated_time ?? null,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export async function createBillableProjectViaApi(
|
||||
ctx: TestContext,
|
||||
data: { name: string; billable_rate?: number | null }
|
||||
|
||||
@@ -4,11 +4,17 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { computed, ref } from 'vue';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import ProjectTableHeading, {
|
||||
type SortColumn,
|
||||
type SortDirection,
|
||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
|
||||
export type SortColumn =
|
||||
| 'name'
|
||||
| 'client_name'
|
||||
| 'spent_time'
|
||||
| 'progress'
|
||||
| 'billable_rate'
|
||||
| 'status';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
@@ -31,7 +37,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: SortColumn];
|
||||
sort: [column: SortColumn, direction: SortDirection];
|
||||
}>();
|
||||
|
||||
const { clients } = useClientsQuery();
|
||||
@@ -45,7 +51,7 @@ const clientNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
// Convert our sort state to TanStack Table format
|
||||
// Convert sort props to TanStack Table format
|
||||
const sorting = computed<SortingState>(() => [
|
||||
{
|
||||
id: props.sortColumn,
|
||||
@@ -53,7 +59,9 @@ const sorting = computed<SortingState>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
// Define column accessors for sorting
|
||||
// Define column accessors for sorting.
|
||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||
const columns = [
|
||||
{
|
||||
id: 'name',
|
||||
@@ -61,17 +69,29 @@ const columns = [
|
||||
},
|
||||
{
|
||||
id: 'client_name',
|
||||
sortUndefined: 'last' as const,
|
||||
accessorFn: (row: Project) => {
|
||||
if (!row.client_id) return '';
|
||||
if (!row.client_id) return undefined;
|
||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spent_time',
|
||||
sortDescFirst: true,
|
||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'progress',
|
||||
sortDescFirst: true,
|
||||
sortUndefined: 'last' as const,
|
||||
accessorFn: (row: Project) => {
|
||||
if (!row.estimated_time) return undefined;
|
||||
return (row.spent_time / row.estimated_time) * 100;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'billable_rate',
|
||||
sortDescFirst: true,
|
||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
||||
},
|
||||
{
|
||||
@@ -80,6 +100,19 @@ const columns = [
|
||||
},
|
||||
];
|
||||
|
||||
// Columns with sortDescFirst get desc as default direction on first click.
|
||||
const descFirstColumns = new Set<SortColumn>(
|
||||
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
||||
);
|
||||
|
||||
function handleSort(column: SortColumn) {
|
||||
if (props.sortColumn === column) {
|
||||
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.projects;
|
||||
@@ -99,10 +132,6 @@ const sortedProjects = computed(() => {
|
||||
return table.getRowModel().rows.map((row) => row.original);
|
||||
});
|
||||
|
||||
function handleSort(column: SortColumn) {
|
||||
emit('sort', column);
|
||||
}
|
||||
|
||||
const showCreateProjectModal = ref(false);
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
@@ -133,6 +162,7 @@ const gridTemplate = computed(() => {
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
:sort-column="props.sortColumn"
|
||||
:sort-direction="props.sortDirection"
|
||||
:desc-first-columns="descFirstColumns"
|
||||
@sort="handleSort"></ProjectTableHeading>
|
||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
showBillableRate: boolean;
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
descFirstColumns: ReadonlySet<SortColumn>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,6 +21,18 @@ function handleSort(column: SortColumn) {
|
||||
function isSorted(column: SortColumn): boolean {
|
||||
return props.sortColumn === column;
|
||||
}
|
||||
|
||||
function isChevronDown(column: SortColumn): boolean {
|
||||
if (!isSorted(column)) return false;
|
||||
return props.descFirstColumns.has(column)
|
||||
? props.sortDirection === 'desc'
|
||||
: props.sortDirection === 'asc';
|
||||
}
|
||||
|
||||
function isChevronUp(column: SortColumn): boolean {
|
||||
if (!isSorted(column)) return false;
|
||||
return !isChevronDown(column);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,58 +41,49 @@ function isSorted(column: SortColumn): boolean {
|
||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('name')">
|
||||
Name
|
||||
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('client_name')">
|
||||
Client
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('spent_time')">
|
||||
Total Time
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('progress')">
|
||||
Progress
|
||||
<ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('billable_rate')">
|
||||
Billable Rate
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('status')">
|
||||
Status
|
||||
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
|
||||
@@ -4,10 +4,6 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||
import type {
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
@@ -26,6 +22,7 @@ import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDr
|
||||
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||
|
||||
// Fetch data using TanStack Query
|
||||
const { projects } = useProjectsQuery();
|
||||
@@ -56,14 +53,9 @@ const tableState = useStorage<ProjectTableState>(
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
||||
function handleSort(column: SortColumn) {
|
||||
if (tableState.value.sortColumn === column) {
|
||||
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
tableState.value.sortColumn = column;
|
||||
tableState.value.sortDirection = 'asc';
|
||||
}
|
||||
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||
tableState.value.sortColumn = column;
|
||||
tableState.value.sortDirection = direction;
|
||||
}
|
||||
|
||||
// Filter projects based on current filters
|
||||
@@ -155,9 +147,7 @@ const showBillableRate = computed(() => {
|
||||
data-testid="status-filter-badge"
|
||||
:value="tableState.filters.status"
|
||||
@remove="removeStatusFilter"
|
||||
@update:value="
|
||||
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
||||
" />
|
||||
@update:value="tableState.filters.status = $event as 'active' | 'archived' | 'all'" />
|
||||
|
||||
<ProjectClientFilterBadge
|
||||
v-if="tableState.filters.clientIds.length > 0"
|
||||
|
||||
@@ -12,7 +12,11 @@ export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['projectMembers', getCurrentOrganizationId(), projectIdValue.value]),
|
||||
queryKey: computed(() => [
|
||||
'projectMembers',
|
||||
getCurrentOrganizationId(),
|
||||
projectIdValue.value,
|
||||
]),
|
||||
queryFn: async () => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const pid = projectIdValue.value;
|
||||
|
||||
@@ -7,7 +7,12 @@ export function useTimeEntriesReportQuery(
|
||||
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
|
||||
) {
|
||||
return useQuery<TimeEntryResponse>({
|
||||
queryKey: computed(() => ['timeEntries', 'detailed-report', getCurrentOrganizationId(), unref(filterParams)]),
|
||||
queryKey: computed(() => [
|
||||
'timeEntries',
|
||||
'detailed-report',
|
||||
getCurrentOrganizationId(),
|
||||
unref(filterParams),
|
||||
]),
|
||||
enabled: computed(() => !!getCurrentOrganizationId()),
|
||||
queryFn: () =>
|
||||
api.getTimeEntries({
|
||||
|
||||
@@ -40,6 +40,10 @@ Route::middleware([
|
||||
return Inertia::render('Calendar');
|
||||
})->name('calendar');
|
||||
|
||||
Route::get('/timesheet', function () {
|
||||
return Inertia::render('Timesheet');
|
||||
})->name('timesheet');
|
||||
|
||||
Route::get('/reporting', function () {
|
||||
return Inertia::render('Reporting');
|
||||
})->name('reporting');
|
||||
|
||||
Reference in New Issue
Block a user