add project progress sorting and fix direction ui for number based

columns in the project table
This commit is contained in:
Gregor Vostrak
2026-02-18 16:45:17 +01:00
parent 0fc325363d
commit 88c0c334e9
8 changed files with 286 additions and 116 deletions
+161 -57
View File
@@ -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);
+31
View File
@@ -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">
+5 -15
View File
@@ -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"
+5 -1
View File
@@ -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({
+4
View File
@@ -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');