mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 04:32:15 +01:00
add project progress sorting and fix direction ui for number based
columns in the project table
This commit is contained in:
@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
|
|||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
|
||||||
import {
|
import {
|
||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
createTaskViaApi,
|
createTaskViaApi,
|
||||||
|
createClientViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
archiveProjectViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
|
||||||
@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sorting tests
|
// 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 goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearProjectTableState(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Wait for the table to load
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
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
|
// Helper to get the visual order of our seeded projects by reading
|
||||||
const getProjectNames = async () => {
|
// all row text in a single evaluate call (avoids locator timing issues)
|
||||||
const rows = page
|
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
|
||||||
.getByTestId('project_table')
|
const getOrder = async (): Promise<string[]> => {
|
||||||
.locator('[data-testid="project_table"] > div')
|
const allRowTexts = await page.evaluate(() => {
|
||||||
.filter({ hasNot: page.locator('.border-t') });
|
const table = document.querySelector('[data-testid="project_table"]');
|
||||||
const names: string[] = [];
|
if (!table) return [];
|
||||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
const rows = table.querySelectorAll('[role="row"]');
|
||||||
for (let i = 0; i < count; i++) {
|
return Array.from(rows).map((row) => row.textContent ?? '');
|
||||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
});
|
||||||
const nameCell = row.locator('div').first();
|
const order: string[] = [];
|
||||||
const text = await nameCell.textContent();
|
for (const text of allRowTexts) {
|
||||||
if (text) {
|
const match = seededNames.find((name) => text.includes(name));
|
||||||
names.push(text.trim());
|
if (match) order.push(match);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return names;
|
return order;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click on Name header to sort ascending (default should already be ascending)
|
// Helper: click a column header and wait for sort to apply.
|
||||||
const nameHeader = page.getByText('Name').first();
|
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
|
||||||
await nameHeader.click();
|
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
|
// --- Sort by Name ---
|
||||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
// Default is name asc (A-Z)
|
||||||
|
let order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
|
||||||
|
|
||||||
// Click again to sort descending
|
// Click to toggle to Z-A
|
||||||
await nameHeader.click();
|
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)
|
// --- Sort by Client (text: first click = A-Z, no-client last) ---
|
||||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
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 }) => {
|
// Reverse: Z-A, no-client still last
|
||||||
await goToProjectsOverview(page);
|
await clickSortHeader('Client', 'BBB Project');
|
||||||
await clearProjectTableState(page);
|
order = await getOrder();
|
||||||
await page.reload();
|
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
|
// --- Sort by Total Time (numeric: first click = highest first) ---
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
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
|
// Reverse: lowest first
|
||||||
const statusHeader = page.getByText('Status').first();
|
await clickSortHeader('Total Time', 'AAA Project');
|
||||||
await statusHeader.click();
|
order = await getOrder();
|
||||||
|
expect(order[2]).toBe('CCC Project'); // C=3h last
|
||||||
|
|
||||||
// Sort indicator should be visible
|
// --- Sort by Billable Rate (numeric: first click = highest first) ---
|
||||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
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
|
// Filter tests
|
||||||
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
|
|||||||
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
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 }) => {
|
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 projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const originalTaskName = 'Original Task ' + 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 };
|
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(
|
export async function createBillableProjectViaApi(
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
data: { name: string; billable_rate?: number | null }
|
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 { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading, {
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
type SortColumn,
|
|
||||||
type SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.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 { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
@@ -31,7 +37,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
@@ -45,7 +51,7 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert our sort state to TanStack Table format
|
// Convert sort props to TanStack Table format
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
@@ -61,17 +69,29 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
|
sortUndefined: 'last' as const,
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
if (!row.client_id) return '';
|
if (!row.client_id) return undefined;
|
||||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'spent_time',
|
id: 'spent_time',
|
||||||
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
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',
|
id: 'billable_rate',
|
||||||
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
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({
|
const table = useVueTable({
|
||||||
get data() {
|
get data() {
|
||||||
return props.projects;
|
return props.projects;
|
||||||
@@ -99,10 +132,6 @@ const sortedProjects = computed(() => {
|
|||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -133,6 +162,7 @@ const gridTemplate = computed(() => {
|
|||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:sort-column="props.sortColumn"
|
:sort-column="props.sortColumn"
|
||||||
:sort-direction="props.sortDirection"
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
@sort="handleSort"></ProjectTableHeading>
|
@sort="handleSort"></ProjectTableHeading>
|
||||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
<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>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
sortColumn: SortColumn;
|
sortColumn: SortColumn;
|
||||||
sortDirection: SortDirection;
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,6 +21,18 @@ function handleSort(column: SortColumn) {
|
|||||||
function isSorted(column: SortColumn): boolean {
|
function isSorted(column: SortColumn): boolean {
|
||||||
return props.sortColumn === column;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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"
|
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')">
|
@click="handleSort('name')">
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||||
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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')">
|
@click="handleSort('client_name')">
|
||||||
Client
|
Client
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
|
||||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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')">
|
@click="handleSort('spent_time')">
|
||||||
Total Time
|
Total Time
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
|
||||||
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
<span v-else class="w-4 h-4"></span>
|
||||||
<ChevronUpIcon
|
</div>
|
||||||
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
<div
|
||||||
class="w-4 h-4" />
|
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>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
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"
|
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')">
|
@click="handleSort('billable_rate')">
|
||||||
Billable Rate
|
Billable Rate
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
||||||
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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')">
|
@click="handleSort('status')">
|
||||||
Status
|
Status
|
||||||
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||||
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<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 { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
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 {
|
|
||||||
SortColumn,
|
|
||||||
SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
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 ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
|
|
||||||
// Fetch data using TanStack Query
|
// Fetch data using TanStack Query
|
||||||
const { projects } = useProjectsQuery();
|
const { projects } = useProjectsQuery();
|
||||||
@@ -56,14 +53,9 @@ const tableState = useStorage<ProjectTableState>(
|
|||||||
{ mergeDefaults: true }
|
{ mergeDefaults: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
function handleSort(column: SortColumn) {
|
tableState.value.sortColumn = column;
|
||||||
if (tableState.value.sortColumn === column) {
|
tableState.value.sortDirection = direction;
|
||||||
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
tableState.value.sortColumn = column;
|
|
||||||
tableState.value.sortDirection = 'asc';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter projects based on current filters
|
// Filter projects based on current filters
|
||||||
@@ -155,9 +147,7 @@ const showBillableRate = computed(() => {
|
|||||||
data-testid="status-filter-badge"
|
data-testid="status-filter-badge"
|
||||||
:value="tableState.filters.status"
|
:value="tableState.filters.status"
|
||||||
@remove="removeStatusFilter"
|
@remove="removeStatusFilter"
|
||||||
@update:value="
|
@update:value="tableState.filters.status = $event as 'active' | 'archived' | 'all'" />
|
||||||
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
|
||||||
" />
|
|
||||||
|
|
||||||
<ProjectClientFilterBadge
|
<ProjectClientFilterBadge
|
||||||
v-if="tableState.filters.clientIds.length > 0"
|
v-if="tableState.filters.clientIds.length > 0"
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: computed(() => ['projectMembers', getCurrentOrganizationId(), projectIdValue.value]),
|
queryKey: computed(() => [
|
||||||
|
'projectMembers',
|
||||||
|
getCurrentOrganizationId(),
|
||||||
|
projectIdValue.value,
|
||||||
|
]),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
const pid = projectIdValue.value;
|
const pid = projectIdValue.value;
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export function useTimeEntriesReportQuery(
|
|||||||
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
|
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
|
||||||
) {
|
) {
|
||||||
return useQuery<TimeEntryResponse>({
|
return useQuery<TimeEntryResponse>({
|
||||||
queryKey: computed(() => ['timeEntries', 'detailed-report', getCurrentOrganizationId(), unref(filterParams)]),
|
queryKey: computed(() => [
|
||||||
|
'timeEntries',
|
||||||
|
'detailed-report',
|
||||||
|
getCurrentOrganizationId(),
|
||||||
|
unref(filterParams),
|
||||||
|
]),
|
||||||
enabled: computed(() => !!getCurrentOrganizationId()),
|
enabled: computed(() => !!getCurrentOrganizationId()),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.getTimeEntries({
|
api.getTimeEntries({
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Route::middleware([
|
|||||||
return Inertia::render('Calendar');
|
return Inertia::render('Calendar');
|
||||||
})->name('calendar');
|
})->name('calendar');
|
||||||
|
|
||||||
|
Route::get('/timesheet', function () {
|
||||||
|
return Inertia::render('Timesheet');
|
||||||
|
})->name('timesheet');
|
||||||
|
|
||||||
Route::get('/reporting', function () {
|
Route::get('/reporting', function () {
|
||||||
return Inertia::render('Reporting');
|
return Inertia::render('Reporting');
|
||||||
})->name('reporting');
|
})->name('reporting');
|
||||||
|
|||||||
Reference in New Issue
Block a user