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

View File

@@ -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>

View File

@@ -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">

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"

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;

View File

@@ -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({