mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add filters and sorting to projects table
This commit is contained in:
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) {
|
|||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to clear localStorage before tests that check persistence
|
||||||
|
async function clearProjectTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('project-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new project via modal
|
||||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
@@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async
|
|||||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to select a status filter using the new dropdown UI
|
||||||
|
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
|
||||||
|
// Click the Filter button to open the dropdown
|
||||||
|
await page.getByRole('button', { name: 'Filter projects' }).click();
|
||||||
|
// Click on Status submenu
|
||||||
|
await page.getByRole('menuitem', { name: 'Status' }).click();
|
||||||
|
// Select the status option
|
||||||
|
await page.getByRole('menuitem', { name: status }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to remove status filter by clicking the X on the badge
|
||||||
|
async function removeStatusFilter(page: Page) {
|
||||||
|
const statusBadge = page.getByTestId('status-filter-badge');
|
||||||
|
// Click the remove button (second button in the badge, contains XMarkIcon)
|
||||||
|
await statusBadge.locator('button').last().click();
|
||||||
|
}
|
||||||
|
|
||||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Archive the project
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await Promise.all([
|
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||||
page.getByRole('menuitem').getByText('Archive').first().click(),
|
|
||||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
|
||||||
]);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('tab', { name: 'Archived' }).click(),
|
|
||||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
// Project should still be visible since default is "all" (no filter)
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Apply Active filter - archived project should disappear
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Remove Active filter and apply Archived filter
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await selectStatusFilter(page, 'Archived');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Unarchive the project
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await Promise.all([
|
await page.getByRole('menuitem').getByText('Unarchive').first().click();
|
||||||
page.getByRole('menuitem').getByText('Unarchive').first().click(),
|
|
||||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
// Project should disappear from Archived view
|
||||||
]);
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('tab', { name: 'Active' }).click(),
|
// Remove Archived filter and apply Active filter to see the project
|
||||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
await removeStatusFilter(page);
|
||||||
]);
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
||||||
@@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sorting tests
|
||||||
|
test('test that sorting projects by name works', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Wait for the table to load
|
||||||
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
|
|
||||||
|
// Get initial project names
|
||||||
|
const getProjectNames = async () => {
|
||||||
|
const rows = page
|
||||||
|
.getByTestId('project_table')
|
||||||
|
.locator('[data-testid="project_table"] > div')
|
||||||
|
.filter({ hasNot: page.locator('.border-t') });
|
||||||
|
const names: string[] = [];
|
||||||
|
const count = await page.getByTestId('project_table').getByRole('row').count();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
||||||
|
const nameCell = row.locator('div').first();
|
||||||
|
const text = await nameCell.textContent();
|
||||||
|
if (text) {
|
||||||
|
names.push(text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click on Name header to sort ascending (default should already be ascending)
|
||||||
|
const nameHeader = page.getByText('Name').first();
|
||||||
|
await nameHeader.click();
|
||||||
|
|
||||||
|
// Wait for sort to apply
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Click again to sort descending
|
||||||
|
await nameHeader.click();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Verify the sort indicator is showing descending
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting projects by status works', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Default is "all" so no filter needed - Wait for the table to load
|
||||||
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
|
|
||||||
|
// Click on Status header to sort
|
||||||
|
const statusHeader = page.getByText('Status').first();
|
||||||
|
await statusHeader.click();
|
||||||
|
|
||||||
|
// Wait for sort to apply
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Sort indicator should be visible
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter tests
|
||||||
|
test('test that filtering projects by status works', async ({ page }) => {
|
||||||
|
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Create a new project
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Archive the project
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||||
|
|
||||||
|
// Project should still be visible (default is "all" - no filter)
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Apply Active filter - archived project should disappear
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Remove Active filter - project should reappear (back to "all")
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Apply Archived filter - project should still be visible
|
||||||
|
await selectStatusFilter(page, 'Archived');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Remove Archived filter and apply Active filter - project should not be visible
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that filter state persists after page reload', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Apply Active status filter
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
|
||||||
|
// Verify the filter badge is visible
|
||||||
|
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for the state to be saved
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the filter badge is still visible after reload
|
||||||
|
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Click on Name header twice to sort descending
|
||||||
|
const nameHeader = page.getByText('Name').first();
|
||||||
|
await nameHeader.click();
|
||||||
|
await nameHeader.click();
|
||||||
|
|
||||||
|
// Wait for the state to be saved
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify descending sort indicator is visible on Name column
|
||||||
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// Create new project with new Client
|
// Create new project with new Client
|
||||||
|
|
||||||
// Create new project with existing Client
|
// Create new project with existing Client
|
||||||
@@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async
|
|||||||
|
|
||||||
// Test that project task count is displayed correctly
|
// Test that project task count is displayed correctly
|
||||||
|
|
||||||
// Test that active / archive / all filter works (once implemented)
|
|
||||||
|
|
||||||
// Edit Project Modal Test
|
// Edit Project Modal Test
|
||||||
|
|
||||||
// Add Project with billable rate
|
// Add Project with billable rate
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</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>
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Email
|
Email
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</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>
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</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 font-semibold text-text-primary">Status</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>
|
||||||
|
|||||||
50
resources/js/Components/Common/Project/BaseFilterBadge.vue
Normal file
50
resources/js/Components/Common/Project/BaseFilterBadge.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/Components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
icon: Component;
|
||||||
|
label: string;
|
||||||
|
filterName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
remove: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default(): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors">
|
||||||
|
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
|
||||||
|
<span class="font-medium text-foreground">{{ filterName }}</span>
|
||||||
|
<span class="text-muted-foreground">is</span>
|
||||||
|
<span class="text-foreground">{{ label }}</span>
|
||||||
|
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
|
||||||
|
@click="$emit('remove')">
|
||||||
|
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { UserGroupIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
|
||||||
|
import BaseFilterBadge from './BaseFilterBadge.vue';
|
||||||
|
import type { Client } from '@/packages/api/src';
|
||||||
|
import { NO_CLIENT_ID } from './constants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: string[];
|
||||||
|
clients: Client[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
remove: [];
|
||||||
|
'update:value': [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const count = props.value.length;
|
||||||
|
|
||||||
|
if (count === 0) return 'None';
|
||||||
|
if (count === 1) {
|
||||||
|
if (hasNoClient.value) return 'No client';
|
||||||
|
const client = props.clients.find((c) => c.id === props.value[0]);
|
||||||
|
return client?.name ?? 'Client';
|
||||||
|
}
|
||||||
|
return `${count} selected`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleClient(clientId: string) {
|
||||||
|
const clientIds = props.value.includes(clientId)
|
||||||
|
? props.value.filter((id) => id !== clientId)
|
||||||
|
: [...props.value, clientId];
|
||||||
|
|
||||||
|
emit('update:value', clientIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNoClient() {
|
||||||
|
const clientIds = hasNoClient.value
|
||||||
|
? props.value.filter((id) => id !== NO_CLIENT_ID)
|
||||||
|
: [...props.value, NO_CLIENT_ID];
|
||||||
|
|
||||||
|
emit('update:value', clientIds);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseFilterBadge
|
||||||
|
:icon="UserGroupIcon"
|
||||||
|
:label="label"
|
||||||
|
filter-name="Client"
|
||||||
|
@remove="emit('remove')">
|
||||||
|
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
|
||||||
|
No client
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-for="client in clients"
|
||||||
|
:key="client.id"
|
||||||
|
:model-value="value.includes(client.id)"
|
||||||
|
@select.prevent="toggleClient(client.id)">
|
||||||
|
{{ client.name }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</BaseFilterBadge>
|
||||||
|
</template>
|
||||||
@@ -130,7 +130,7 @@ function updateValue(project: Project) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a project..."
|
placeholder="Search for a project..."
|
||||||
@keydown.enter="addProjectIfNoneExists" />
|
@keydown.enter="addProjectIfNoneExists" />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CircleStackIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
|
||||||
|
import BaseFilterBadge from './BaseFilterBadge.vue';
|
||||||
|
|
||||||
|
type StatusValue = 'active' | 'archived' | 'all';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: StatusValue;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
remove: [];
|
||||||
|
'update:value': [value: StatusValue];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ id: 'active' as const, name: 'Active' },
|
||||||
|
{ id: 'archived' as const, name: 'Archived' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStatus(status: StatusValue) {
|
||||||
|
emit('update:value', status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseFilterBadge
|
||||||
|
:icon="CircleStackIcon"
|
||||||
|
:label="label"
|
||||||
|
filter-name="Status"
|
||||||
|
@remove="emit('remove')">
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="option in statusOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:class="[value === option.id && 'bg-accent text-accent-foreground']"
|
||||||
|
@click="updateStatus(option.id)">
|
||||||
|
{{ option.name }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</BaseFilterBadge>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,10 @@ 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 from '@/Components/Common/Project/ProjectTableHeading.vue';
|
import ProjectTableHeading, {
|
||||||
|
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';
|
||||||
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';
|
||||||
@@ -12,13 +15,96 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
|
|
||||||
|
// Create a map of client names for sorting
|
||||||
|
const clientNameMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
clients.value.forEach((client) => {
|
||||||
|
map.set(client.id, client.name);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert our sort state to TanStack Table format
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Define column accessors for sorting
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'client_name',
|
||||||
|
accessorFn: (row: Project) => {
|
||||||
|
if (!row.client_id) return '';
|
||||||
|
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spent_time',
|
||||||
|
accessorFn: (row: Project) => row.spent_time ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'billable_rate',
|
||||||
|
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return props.projects;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedProjects = computed(() => {
|
||||||
|
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> {
|
||||||
return await useProjectsStore().createProject(project);
|
return await useProjectsStore().createProject(project);
|
||||||
}
|
}
|
||||||
@@ -26,11 +112,10 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
|
|||||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||||
return await useClientsStore().createClient(client);
|
return await useClientsStore().createClient(client);
|
||||||
}
|
}
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
|
||||||
const gridTemplate = computed(() => {
|
const gridTemplate = computed(() => {
|
||||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
||||||
});
|
});
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,8 +130,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
||||||
<ProjectTableHeading
|
<ProjectTableHeading
|
||||||
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
|
:show-billable-rate="props.showBillableRate"
|
||||||
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
@sort="handleSort"></ProjectTableHeading>
|
||||||
|
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">
|
<h3 class="text-text-primary font-semibold">
|
||||||
{{
|
{{
|
||||||
@@ -69,7 +157,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|||||||
>Create your First Project
|
>Create your First Project
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="project in projects" :key="project.id">
|
<template v-for="project in sortedProjects" :key="project.id">
|
||||||
<ProjectTableRow
|
<ProjectTableRow
|
||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:project="project"></ProjectTableRow>
|
:project="project"></ProjectTableRow>
|
||||||
|
|||||||
@@ -1,23 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
defineProps<{
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
|
||||||
|
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(column: SortColumn): boolean {
|
||||||
|
return props.sortColumn === column;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
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="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
||||||
|
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 font-semibold text-text-primary">Client</div>
|
<div
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</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"
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
@click="handleSort('client_name')">
|
||||||
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
Client
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('spent_time')">
|
||||||
|
Total Time
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
||||||
|
<div
|
||||||
|
v-if="showBillableRate"
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('billable_rate')">
|
||||||
Billable Rate
|
Billable Rate
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('status')">
|
||||||
|
Status
|
||||||
|
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||||
import type { Project } from '@/packages/api/src';
|
import type { Project } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
@@ -116,9 +116,15 @@ const showEditProjectModal = ref(false);
|
|||||||
{{ billableRateInfo }}
|
{{ billableRateInfo }}
|
||||||
</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 class="w-5"></CheckCircleIcon>
|
<template v-if="project.is_archived">
|
||||||
<span>Active</span>
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
|
<span>Archived</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
|
<span>Active</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium 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 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/Components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/packages/ui/src';
|
||||||
|
import type { Client } from '@/packages/api/src';
|
||||||
|
import { NO_CLIENT_ID } from './constants';
|
||||||
|
|
||||||
|
export interface ProjectFilters {
|
||||||
|
status: 'active' | 'archived' | 'all';
|
||||||
|
clientIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filters: ProjectFilters;
|
||||||
|
clients: Client[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:filters': [filters: ProjectFilters];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ id: 'active' as const, name: 'Active' },
|
||||||
|
{ id: 'archived' as const, name: 'Archived' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
function updateStatus(status: 'active' | 'archived' | 'all') {
|
||||||
|
emit('update:filters', {
|
||||||
|
...props.filters,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClient(clientId: string) {
|
||||||
|
const clientIds = props.filters.clientIds.includes(clientId)
|
||||||
|
? props.filters.clientIds.filter((id) => id !== clientId)
|
||||||
|
: [...props.filters.clientIds, clientId];
|
||||||
|
|
||||||
|
emit('update:filters', {
|
||||||
|
...props.filters,
|
||||||
|
clientIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNoClient() {
|
||||||
|
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
|
||||||
|
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
|
||||||
|
: [...props.filters.clientIds, NO_CLIENT_ID];
|
||||||
|
|
||||||
|
emit('update:filters', {
|
||||||
|
...props.filters,
|
||||||
|
clientIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() => {
|
||||||
|
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu v-model:open="open">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" size="xs" aria-label="Filter projects">
|
||||||
|
<ListFilterIcon
|
||||||
|
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
|
||||||
|
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" class="w-56">
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger class="gap-2">
|
||||||
|
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span>Status</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="option in statusOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:class="[
|
||||||
|
filters.status === option.id && 'bg-accent text-accent-foreground',
|
||||||
|
]"
|
||||||
|
@click="updateStatus(option.id)">
|
||||||
|
{{ option.name }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
|
<!-- Client Filter -->
|
||||||
|
<DropdownMenuSub v-if="clients.length > 0">
|
||||||
|
<DropdownMenuSubTrigger class="gap-2">
|
||||||
|
<UserGroupIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span>Client</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
|
||||||
|
@select.prevent="toggleNoClient">
|
||||||
|
No client
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-for="client in clients"
|
||||||
|
:key="client.id"
|
||||||
|
:model-value="filters.clientIds.includes(client.id)"
|
||||||
|
@select.prevent="toggleClient(client.id)">
|
||||||
|
{{ client.name }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
1
resources/js/Components/Common/Project/constants.ts
Normal file
1
resources/js/Components/Common/Project/constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const NO_CLIENT_ID = '__no_client__';
|
||||||
@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</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 font-semibold text-text-primary">Role</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</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>
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
|
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Name
|
Name
|
||||||
</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,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
|
||||||
Task Name
|
Task Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
||||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</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>
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ const delegatedProps = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<TabsList
|
<TabsList
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
|
||||||
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
|
|
||||||
">
|
|
||||||
<slot />
|
<slot />
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
|||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/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 { computed, onMounted, ref } from 'vue';
|
import type {
|
||||||
|
SortColumn,
|
||||||
|
SortDirection,
|
||||||
|
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
|
||||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
@@ -18,31 +21,95 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
|||||||
import { getCurrentRole } from '@/utils/useUser';
|
import { getCurrentRole } from '@/utils/useUser';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
|
||||||
|
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||||
|
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||||
|
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||||
|
|
||||||
|
// Fetch data using TanStack Query
|
||||||
|
const { projects } = useProjectsQuery();
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
useProjectsStore().fetchProjects();
|
|
||||||
useOrganizationStore().fetchOrganization();
|
|
||||||
});
|
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
const showCreateProjectModal = ref(false);
|
|
||||||
|
|
||||||
const { organization } = storeToRefs(useOrganizationStore());
|
const { organization } = storeToRefs(useOrganizationStore());
|
||||||
|
|
||||||
const activeTab = ref<'active' | 'archived'>('active');
|
// Table state persisted in localStorage
|
||||||
|
interface ProjectTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
filters: {
|
||||||
|
clientIds: string[];
|
||||||
|
status: 'active' | 'archived' | 'all';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { projects } = storeToRefs(useProjectsStore());
|
const tableState = useStorage<ProjectTableState>(
|
||||||
|
'project-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
filters: {
|
||||||
|
clientIds: [],
|
||||||
|
status: 'all',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
const shownProjects = computed(() => {
|
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
if (tableState.value.sortColumn === column) {
|
||||||
|
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter projects based on current filters
|
||||||
|
const filteredProjects = computed(() => {
|
||||||
return projects.value.filter((project) => {
|
return projects.value.filter((project) => {
|
||||||
if (activeTab.value === 'active') {
|
// Status filter
|
||||||
return !project.is_archived;
|
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return project.is_archived;
|
if (tableState.value.filters.status === 'archived' && !project.is_archived) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client filter
|
||||||
|
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
|
||||||
|
if (hasClientFilter) {
|
||||||
|
const matchesNoClient =
|
||||||
|
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
|
||||||
|
const matchesClientId =
|
||||||
|
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
|
||||||
|
|
||||||
|
if (!matchesNoClient && !matchesClientId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper functions for active filters
|
||||||
|
function removeStatusFilter() {
|
||||||
|
tableState.value.filters.status = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClientFilter() {
|
||||||
|
tableState.value.filters.clientIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCreateProjectModal = useStorage('project-create-modal-open', false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
return await useProjectsStore().createProject(project);
|
return await useProjectsStore().createProject(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||||
return await useClientsStore().createClient(client);
|
return await useClientsStore().createClient(client);
|
||||||
}
|
}
|
||||||
@@ -57,13 +124,9 @@ const showBillableRate = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout title="Projects" data-testid="projects_view">
|
<AppLayout title="Projects" data-testid="projects_view">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
||||||
<TabBar v-model="activeTab">
|
|
||||||
<TabBarItem value="active">Active</TabBarItem>
|
|
||||||
<TabBarItem value="archived">Archived</TabBarItem>
|
|
||||||
</TabBar>
|
|
||||||
</div>
|
</div>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="canCreateProjects()"
|
v-if="canCreateProjects()"
|
||||||
@@ -80,8 +143,38 @@ const showBillableRate = computed(() => {
|
|||||||
:clients="clients"
|
:clients="clients"
|
||||||
@submit="createProject"></ProjectCreateModal>
|
@submit="createProject"></ProjectCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
<MainContainer>
|
||||||
|
<div class="flex items-center gap-2 py-1">
|
||||||
|
<ProjectsFilterDropdown
|
||||||
|
:filters="tableState.filters"
|
||||||
|
:clients="clients"
|
||||||
|
@update:filters="tableState.filters = $event" />
|
||||||
|
|
||||||
|
<!-- Active Filters -->
|
||||||
|
<ProjectStatusFilterBadge
|
||||||
|
v-if="tableState.filters.status !== 'all'"
|
||||||
|
data-testid="status-filter-badge"
|
||||||
|
:value="tableState.filters.status"
|
||||||
|
@remove="removeStatusFilter"
|
||||||
|
@update:value="
|
||||||
|
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
||||||
|
" />
|
||||||
|
|
||||||
|
<ProjectClientFilterBadge
|
||||||
|
v-if="tableState.filters.clientIds.length > 0"
|
||||||
|
data-testid="client-filter-badge"
|
||||||
|
:value="tableState.filters.clientIds"
|
||||||
|
:clients="clients"
|
||||||
|
@remove="removeClientFilter"
|
||||||
|
@update:value="tableState.filters.clientIds = $event as string[]" />
|
||||||
|
</div>
|
||||||
|
</MainContainer>
|
||||||
|
|
||||||
<ProjectTable
|
<ProjectTable
|
||||||
:show-billable-rate="showBillableRate"
|
:show-billable-rate="showBillableRate"
|
||||||
:projects="shownProjects"></ProjectTable>
|
:projects="filteredProjects"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></ProjectTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a client..." />
|
placeholder="Search for a client..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
|
|||||||
20
resources/js/packages/ui/src/Icons/ListFilterIcon.vue
Normal file
20
resources/js/packages/ui/src/Icons/ListFilterIcon.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path d="M2 5h20" />
|
||||||
|
<path d="M6 12h12" />
|
||||||
|
<path d="M9 19h6" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -145,7 +145,7 @@ const highlightedItem = computed(() => {
|
|||||||
<input
|
<input
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:value="searchValue"
|
:value="searchValue"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
:placeholder="searchPlaceholder"
|
:placeholder="searchPlaceholder"
|
||||||
@input="updateSearchValue"
|
@input="updateSearchValue"
|
||||||
@keydown.up.prevent="moveHighlightUp"
|
@keydown.up.prevent="moveHighlightUp"
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const showCreateTagModal = ref(false);
|
|||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:value="searchValue"
|
:value="searchValue"
|
||||||
data-testid="tag_dropdown_search"
|
data-testid="tag_dropdown_search"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a Tag..."
|
placeholder="Search for a Tag..."
|
||||||
@input="updateSearchValue"
|
@input="updateSearchValue"
|
||||||
@keydown.esc.prevent="open = false"
|
@keydown.esc.prevent="open = false"
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ const showCreateProject = ref(false);
|
|||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:value="searchValue"
|
:value="searchValue"
|
||||||
data-testid="client_dropdown_search"
|
data-testid="client_dropdown_search"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a project or task..."
|
placeholder="Search for a project or task..."
|
||||||
@input="updateSearchValue"
|
@input="updateSearchValue"
|
||||||
@keydown.enter.prevent="addClientIfNoneExists"
|
@keydown.enter.prevent="addClientIfNoneExists"
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function closeAndFocusInput() {
|
|||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
placeholder="00:00:00"
|
placeholder="00:00:00"
|
||||||
data-testid="time_entry_time"
|
data-testid="time_entry_time"
|
||||||
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
@focusin="openModalOnTab"
|
@focusin="openModalOnTab"
|
||||||
@click="openModalOnClick"
|
@click="openModalOnClick"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
--theme-color-row-background: var(--color-bg-primary);
|
--theme-color-row-background: var(--color-bg-primary);
|
||||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
--theme-color-row-heading-background: var(--color-bg-primary);
|
||||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||||
--theme-color-icon-default: var(--color-text-tertiary);
|
--theme-color-icon-default: var(--color-text-tertiary);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
:root.light {
|
:root.light {
|
||||||
--color-bg-primary: #ffffff;
|
--color-bg-primary: #ffffff;
|
||||||
--color-bg-secondary: #f7f7f8;
|
--color-bg-secondary: #fcfcfc;
|
||||||
--color-bg-tertiary: #eeeeef;
|
--color-bg-tertiary: #eeeeef;
|
||||||
--color-bg-quaternary: #e1e1e3;
|
--color-bg-quaternary: #e1e1e3;
|
||||||
--color-bg-background: #f5f5f5;
|
--color-bg-background: #f5f5f5;
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
--theme-color-row-background: var(--theme-color-card-background);
|
--theme-color-row-background: var(--theme-color-primary);
|
||||||
--theme-color-row-heading-background: var(--color-bg-secondary);
|
--theme-color-row-heading-background: var(--theme-color-primary);
|
||||||
--theme-color-row-heading-border: var(--color-border-tertiary);
|
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||||
--theme-color-icon-default: var(--color-text-quaternary);
|
--theme-color-icon-default: var(--color-text-quaternary);
|
||||||
|
|
||||||
@@ -142,28 +142,8 @@
|
|||||||
* {
|
* {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-bg-tertiary) transparent;
|
||||||
/* width */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track */
|
|
||||||
::-webkit-scrollbar-track,
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle on hover */
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[x-cloak] {
|
[x-cloak] {
|
||||||
@@ -188,8 +168,8 @@ body {
|
|||||||
--secondary-foreground: var(--color-text-primary);
|
--secondary-foreground: var(--color-text-primary);
|
||||||
--muted: var(--color-bg-tertiary);
|
--muted: var(--color-bg-tertiary);
|
||||||
--muted-foreground: var(--color-text-tertiary);
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
--accent: var(--theme-color-button-primary-background);
|
--accent: var(--color-bg-tertiary);
|
||||||
--accent-foreground: var(--theme-color-button-primary-text);
|
--accent-foreground: var(--color-text-primary);
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
@@ -215,8 +195,8 @@ body {
|
|||||||
--secondary-foreground: var(--color-text-primary);
|
--secondary-foreground: var(--color-text-primary);
|
||||||
--muted: var(--color-bg-tertiary);
|
--muted: var(--color-bg-tertiary);
|
||||||
--muted-foreground: var(--color-text-tertiary);
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
--accent: var(--theme-color-button-primary-background);
|
--accent: var(--color-bg-tertiary);
|
||||||
--accent-foreground: var(--theme-color-button-primary-text);
|
--accent-foreground: var(--color-text-primary);
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const solidtimeTheme = {
|
|||||||
foreground: 'var(--primary-foreground)',
|
foreground: 'var(--primary-foreground)',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'var(--secondary)',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
},
|
},
|
||||||
tertiary: 'var(--color-bg-tertiary)',
|
tertiary: 'var(--color-bg-tertiary)',
|
||||||
@@ -60,8 +60,8 @@ export const solidtimeTheme = {
|
|||||||
'card-border': 'var(--theme-color-card-border)',
|
'card-border': 'var(--theme-color-card-border)',
|
||||||
'card-border-active': 'var(--theme-color-card-border-active)',
|
'card-border-active': 'var(--theme-color-card-border-active)',
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'var(--muted)',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'var(--muted-foreground)',
|
||||||
},
|
},
|
||||||
'tab-background': 'var(--theme-color-tab-background)',
|
'tab-background': 'var(--theme-color-tab-background)',
|
||||||
'tab-background-active': 'var(--theme-color-tab-background-active)',
|
'tab-background-active': 'var(--theme-color-tab-background-active)',
|
||||||
@@ -90,8 +90,8 @@ export const solidtimeTheme = {
|
|||||||
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
|
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
|
||||||
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
|
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
|
||||||
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
|
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
|
||||||
DEFAULT: 'var(--color-accent-default)',
|
DEFAULT: 'var(--accent)',
|
||||||
foreground: 'var(--color-accent-foreground)',
|
foreground: 'var(--accent-foreground)',
|
||||||
},
|
},
|
||||||
'button-primary-background': 'var(--theme-color-button-primary-background)',
|
'button-primary-background': 'var(--theme-color-button-primary-background)',
|
||||||
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
|
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
|
|
||||||
export const useProjectsStore = defineStore('projects', () => {
|
export const useProjectsStore = defineStore('projects', () => {
|
||||||
const projectResponse = ref<ProjectResponse | null>(null);
|
const projectResponse = ref<ProjectResponse | null>(null);
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
function invalidateProjectsQuery() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
}
|
||||||
async function fetchProjects() {
|
async function fetchProjects() {
|
||||||
const organization = getCurrentOrganizationId();
|
const organization = getCurrentOrganizationId();
|
||||||
if (organization) {
|
if (organization) {
|
||||||
@@ -48,6 +54,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await fetchProjects();
|
await fetchProjects();
|
||||||
|
invalidateProjectsQuery();
|
||||||
return response['data'];
|
return response['data'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +74,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
'Failed to delete project'
|
'Failed to delete project'
|
||||||
);
|
);
|
||||||
await fetchProjects();
|
await fetchProjects();
|
||||||
|
invalidateProjectsQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
'Failed to update project'
|
'Failed to update project'
|
||||||
);
|
);
|
||||||
await fetchProjects();
|
await fetchProjects();
|
||||||
|
invalidateProjectsQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
resources/js/utils/useProjectsQuery.ts
Normal file
34
resources/js/utils/useProjectsQuery.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import { api } from '@/packages/api/src';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import type { Project } from '@/packages/api/src';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export function useProjectsQuery() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (!organizationId) throw new Error('No organization');
|
||||||
|
return api.getProjects({
|
||||||
|
params: { organization: organizationId },
|
||||||
|
queries: { archived: 'all' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: () => !!getCurrentOrganizationId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = computed<Project[]>(() => query.data.value?.data ?? []);
|
||||||
|
|
||||||
|
const invalidateProjects = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
projects,
|
||||||
|
invalidateProjects,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export default {
|
|||||||
'./resources/views/**/*.blade.php',
|
'./resources/views/**/*.blade.php',
|
||||||
'./resources/js/**/*.vue',
|
'./resources/js/**/*.vue',
|
||||||
'./resources/js/**/*.ts',
|
'./resources/js/**/*.ts',
|
||||||
|
'!./resources/js/**/node_modules',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
Reference in New Issue
Block a user