mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add table sorting to members, clients and tags table
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToClientsOverview(page: Page) {
|
async function goToClientsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
@@ -131,6 +132,96 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
|
|||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearClientTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('client-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
||||||
|
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
||||||
|
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is name asc) --
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
await statusHeader.click(); // desc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
|
// Create projects for the first client
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Projects header - first click should sort desc (most projects first)
|
||||||
|
const projectsHeader = table.getByText('Projects').first();
|
||||||
|
await projectsHeader.click();
|
||||||
|
await expect(projectsHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ManyProjects Client')).toBeLessThan(
|
||||||
|
names.indexOf('NoProjects Client')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second click toggles to asc (least projects first)
|
||||||
|
await projectsHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('NoProjects Client')).toBeLessThan(
|
||||||
|
names.indexOf('ManyProjects Client')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('client_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { expect, test } from '../playwright/fixtures';
|
|||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { inviteAndAcceptMember } from './utils/members';
|
import { inviteAndAcceptMember } from './utils/members';
|
||||||
import { createPlaceholderMemberViaImportApi } from './utils/api';
|
import {
|
||||||
|
createPlaceholderMemberViaImportApi,
|
||||||
|
getMembersViaApi,
|
||||||
|
updateMemberBillableRateViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
// Tests that invite + accept members need more time
|
// Tests that invite + accept members need more time
|
||||||
test.describe.configure({ timeout: 45000 });
|
test.describe.configure({ timeout: 45000 });
|
||||||
@@ -487,6 +492,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
|
|||||||
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Helper to clear localStorage before tests that check sorting
|
||||||
|
async function clearMemberTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('member-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members with names that sort predictably around "John Doe"
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is already name asc after clearing state) --
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
// -- Role sorting --
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
const ownerIdx = names.indexOf('John Doe');
|
||||||
|
const placeholderIdx = names.indexOf('AAA SortFirst');
|
||||||
|
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
||||||
|
|
||||||
|
await roleHeader.click(); // desc: Placeholder first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
await statusHeader.click(); // desc: Inactive first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Email: just verify sort indicator appears --
|
||||||
|
const emailHeader = table.getByText('Email').first();
|
||||||
|
await emailHeader.click();
|
||||||
|
await expect(emailHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Role header twice to set descending sort
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the sort indicator is still visible on Role column
|
||||||
|
await expect(page.getByTestId('member_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('member_table').getByText('Role').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members and set different billable rates
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
|
||||||
|
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const highRateMember = members.find((m) => m.name === 'HighRate Member');
|
||||||
|
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
|
||||||
|
expect(highRateMember).toBeDefined();
|
||||||
|
expect(lowRateMember).toBeDefined();
|
||||||
|
|
||||||
|
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
|
||||||
|
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// First click = desc (highest first), null rates last
|
||||||
|
const billableHeader = table.getByText('Billable Rate').first();
|
||||||
|
await billableHeader.click();
|
||||||
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
|
||||||
|
|
||||||
|
// Second click = asc (lowest first), null rates still last
|
||||||
|
await billableHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -522,7 +646,7 @@ test.describe('Employee Sidebar Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Member table is empty — no rows rendered (only headers)
|
// Member table is empty — no rows rendered (only headers)
|
||||||
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
|
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { createTagViaApi } from './utils/api';
|
import { createTagViaApi } from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToTagsOverview(page: Page) {
|
async function goToTagsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
||||||
@@ -89,6 +90,57 @@ test('test that multiple tags can be created via API and displayed in the table'
|
|||||||
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearTagTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('tag-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting tags by name works', async ({ page, ctx }) => {
|
||||||
|
await createTagViaApi(ctx, { name: 'AAA SortTag' });
|
||||||
|
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
|
||||||
|
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
await clearTagTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('tag_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Default is name asc
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that tag sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
await clearTagTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('tag_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('tag_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('tag_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -345,6 +345,36 @@ export async function createProjectMemberViaApi(
|
|||||||
return body.data as { id: string; billable_rate: number | null };
|
return body.data as { id: string; billable_rate: number | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMembersViaApi(ctx: TestContext) {
|
||||||
|
const response = await ctx.request.get(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
billable_rate: number | null;
|
||||||
|
is_placeholder: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemberBillableRateViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
memberId: string,
|
||||||
|
billableRate: number | null
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.put(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,
|
||||||
|
{ data: { billable_rate: billableRate } }
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Composite helpers (matching existing UI helper signatures)
|
// Composite helpers (matching existing UI helper signatures)
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,17 +2,102 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { type Component, ref } from 'vue';
|
import { type Component, computed, ref } from 'vue';
|
||||||
import { type Client } from '@/packages/api/src';
|
import { type Client } from '@/packages/api/src';
|
||||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
defineProps<{
|
export type SortColumn = 'name' | 'projects_count' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const createClient = ref(false);
|
const createClient = ref(false);
|
||||||
|
|
||||||
|
const { projects } = useProjectsQuery();
|
||||||
|
|
||||||
|
const projectCountMap = computed(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
projects.value.forEach((project) => {
|
||||||
|
if (project.client_id) {
|
||||||
|
map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Client) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects_count',
|
||||||
|
sortDescFirst: true,
|
||||||
|
accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row: Client) => (row.is_archived ? 1 : 0),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.value.filter((c) => 'sortDescFirst' in 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.clients;
|
||||||
|
},
|
||||||
|
get columns() {
|
||||||
|
return columns.value;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedClients = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,8 +108,12 @@ const createClient = ref(false);
|
|||||||
data-testid="client_table"
|
data-testid="client_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||||
<ClientTableHeading></ClientTableHeading>
|
<ClientTableHeading
|
||||||
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></ClientTableHeading>
|
||||||
|
<div v-if="sortedClients.length === 0" class="col-span-3 py-24 text-center">
|
||||||
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
||||||
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
|
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
|
||||||
@@ -35,7 +124,7 @@ const createClient = ref(false);
|
|||||||
>Create your First Client
|
>Create your First Client
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="client in clients" :key="client.id">
|
<template v-for="client in sortedClients" :key="client.id">
|
||||||
<ClientTableRow :client="client"></ClientTableRow>
|
<ClientTableRow :client="client"></ClientTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,67 @@
|
|||||||
<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 type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
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
|
Name
|
||||||
|
<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('projects_count')">
|
||||||
|
Projects
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" 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="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>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</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">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ const showEditModal = ref(false);
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
<span>
|
<span>
|
||||||
{{ client.name }}
|
{{ client.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
|
||||||
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,19 +2,117 @@
|
|||||||
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
|
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
|
||||||
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
||||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||||
|
import type { Member } from '@/packages/api/src';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
|
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { members } = useMembersQuery();
|
const { members } = useMembersQuery();
|
||||||
|
|
||||||
|
const roleOrder: Record<string, number> = {
|
||||||
|
owner: 0,
|
||||||
|
admin: 1,
|
||||||
|
manager: 2,
|
||||||
|
employee: 3,
|
||||||
|
placeholder: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Member) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
accessorFn: (row: Member) => row.email.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role',
|
||||||
|
accessorFn: (row: Member) => roleOrder[row.role] ?? 99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'billable_rate',
|
||||||
|
sortDescFirst: true,
|
||||||
|
sortUndefined: 'last' as const,
|
||||||
|
accessorFn: (row: Member) => {
|
||||||
|
if (row.billable_rate === null) return undefined;
|
||||||
|
return row.billable_rate;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row: Member) => (row.is_placeholder ? 1 : 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 members.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedMembers = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div
|
<div
|
||||||
data-testid="client_table"
|
data-testid="member_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
|
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
|
||||||
<MemberTableHeading></MemberTableHeading>
|
<MemberTableHeading
|
||||||
<template v-for="member in members" :key="member.id">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></MemberTableHeading>
|
||||||
|
<template v-for="member in sortedMembers" :key="member.id">
|
||||||
<MemberTableRow :member="member"></MemberTableRow>
|
<MemberTableRow :member="member"></MemberTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,83 @@
|
|||||||
<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 type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
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
|
Name
|
||||||
|
<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('email')">
|
||||||
|
Email
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('email')" 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('role')">
|
||||||
|
Role
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('role')" 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('billable_rate')">
|
||||||
|
Billable Rate
|
||||||
|
<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="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>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Member, Organization } from '@/packages/api/src';
|
import type { Member, Organization } from '@/packages/api/src';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
@@ -82,11 +82,15 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
||||||
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
|
<template v-if="member.is_placeholder === false">
|
||||||
<span v-if="member.is_placeholder === false">Active</span>
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
|
<span>Active</span>
|
||||||
<span v-if="member.is_placeholder === true">Inactive</span>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
|
||||||
|
<span>Inactive</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ 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,
|
// 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).
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
const columns = [
|
const columns = computed(() => [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
@@ -98,11 +98,11 @@ const columns = [
|
|||||||
id: 'status',
|
id: 'status',
|
||||||
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Columns with sortDescFirst get desc as default direction on first click.
|
// Columns with sortDescFirst get desc as default direction on first click.
|
||||||
const descFirstColumns = new Set<SortColumn>(
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
@@ -117,7 +117,9 @@ const table = useVueTable({
|
|||||||
get data() {
|
get data() {
|
||||||
return props.projects;
|
return props.projects;
|
||||||
},
|
},
|
||||||
columns,
|
get columns() {
|
||||||
|
return columns.value;
|
||||||
|
},
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
state: {
|
state: {
|
||||||
|
|||||||
@@ -2,18 +2,80 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
|
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
|
||||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||||
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
|
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
|
||||||
import { canCreateTags } from '@/utils/permissions';
|
import { canCreateTags } from '@/utils/permissions';
|
||||||
import type { Tag } from '@/packages/api/src';
|
import type { Tag } from '@/packages/api/src';
|
||||||
defineProps<{
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
|
export type SortColumn = 'name';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { tags } = useTagsQuery();
|
const { tags } = useTagsQuery();
|
||||||
const showCreateTagModal = ref(false);
|
const showCreateTagModal = ref(false);
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Tag) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.filter((c) => 'sortDescFirst' in 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 tags.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedTags = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,8 +86,12 @@ const showCreateTagModal = ref(false);
|
|||||||
data-testid="tag_table"
|
data-testid="tag_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 80px">
|
style="grid-template-columns: 1fr 80px">
|
||||||
<TagTableHeading></TagTableHeading>
|
<TagTableHeading
|
||||||
<div v-if="tags.length === 0" class="col-span-5 py-24 text-center">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></TagTableHeading>
|
||||||
|
<div v-if="sortedTags.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>
|
||||||
<h3 class="text-text-primary font-semibold">No tags found</h3>
|
<h3 class="text-text-primary font-semibold">No tags found</h3>
|
||||||
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
|
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
|
||||||
@@ -36,7 +102,7 @@ const showCreateTagModal = ref(false);
|
|||||||
>Create your First Tag</SecondaryButton
|
>Create your First Tag</SecondaryButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="tag in tags" :key="tag.id">
|
<template v-for="tag in sortedTags" :key="tag.id">
|
||||||
<TagTableRow :tag="tag"></TagTableRow>
|
<TagTableRow :tag="tag"></TagTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,51 @@
|
|||||||
<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 type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
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
|
Name
|
||||||
|
<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>
|
||||||
<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">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import PageTitle from '@/Components/Common/PageTitle.vue';
|
|||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
||||||
|
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
|
|
||||||
@@ -19,6 +21,26 @@ const activeTab = ref<'active' | 'archived'>('active');
|
|||||||
|
|
||||||
const createClient = ref(false);
|
const createClient = ref(false);
|
||||||
|
|
||||||
|
interface ClientTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<ClientTableState>(
|
||||||
|
'client-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
const shownClients = computed(() => {
|
const shownClients = computed(() => {
|
||||||
return clients.value.filter((client) => {
|
return clients.value.filter((client) => {
|
||||||
if (activeTab.value === 'active') {
|
if (activeTab.value === 'active') {
|
||||||
@@ -45,6 +67,10 @@ const shownClients = computed(() => {
|
|||||||
>
|
>
|
||||||
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
|
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<ClientTable :clients="shownClients"></ClientTable>
|
<ClientTable
|
||||||
|
:clients="shownClients"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></ClientTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { Role } from '@/types/jetstream';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
|
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
|
||||||
import { canCreateInvitations } from '@/utils/permissions';
|
import { canCreateInvitations } from '@/utils/permissions';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
|
|
||||||
const inviteMember = ref(false);
|
const inviteMember = ref(false);
|
||||||
|
|
||||||
@@ -21,6 +23,26 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const activeTab = ref<'all' | 'invitations'>('all');
|
const activeTab = ref<'all' | 'invitations'>('all');
|
||||||
|
|
||||||
|
interface MemberTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<MemberTableState>(
|
||||||
|
'member-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,7 +67,11 @@ const activeTab = ref<'all' | 'invitations'>('all');
|
|||||||
:available-roles="availableRoles"
|
:available-roles="availableRoles"
|
||||||
@close="activeTab = 'invitations'"></MemberInviteModal>
|
@close="activeTab = 'invitations'"></MemberInviteModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MemberTable v-if="activeTab === 'all'"></MemberTable>
|
<MemberTable
|
||||||
|
v-if="activeTab === 'all'"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></MemberTable>
|
||||||
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
|
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ 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="tableState.filters.status = $event as 'active' | 'archived' | 'all'" />
|
@update:value="
|
||||||
|
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
||||||
|
" />
|
||||||
|
|
||||||
<ProjectClientFilterBadge
|
<ProjectClientFilterBadge
|
||||||
v-if="tableState.filters.clientIds.length > 0"
|
v-if="tableState.filters.clientIds.length > 0"
|
||||||
|
|||||||
@@ -9,9 +9,31 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { canCreateTags } from '@/utils/permissions';
|
import { canCreateTags } from '@/utils/permissions';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
|
||||||
|
|
||||||
const showCreateTagModal = ref(false);
|
const showCreateTagModal = ref(false);
|
||||||
|
|
||||||
|
interface TagTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<TagTableState>(
|
||||||
|
'tag-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
async function createTag(tag: string) {
|
async function createTag(tag: string) {
|
||||||
return await useTagsStore().createTag(tag);
|
return await useTagsStore().createTag(tag);
|
||||||
}
|
}
|
||||||
@@ -34,6 +56,10 @@ async function createTag(tag: string) {
|
|||||||
v-model:show="showCreateTagModal"
|
v-model:show="showCreateTagModal"
|
||||||
:create-tag="createTag"></TagCreateModal>
|
:create-tag="createTag"></TagCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TagTable :create-tag="createTag"></TagTable>
|
<TagTable
|
||||||
|
:create-tag="createTag"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></TagTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user