mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 03:32:15 +01:00
add filters and sorting to projects table
This commit is contained in:
@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</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 font-semibold text-text-primary">Status</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">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
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="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>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</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 font-semibold text-text-primary">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 font-semibold text-text-primary">Status</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">
|
||||
<span class="sr-only">Edit</span>
|
||||
</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>
|
||||
<ComboboxInput
|
||||
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..."
|
||||
@keydown.enter="addProjectIfNoneExists" />
|
||||
</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 { computed, ref } from '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 { canCreateProjects } from '@/utils/permissions';
|
||||
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 { storeToRefs } from 'pinia';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
} from '@tanstack/vue-table';
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
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);
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
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> {
|
||||
return await useClientsStore().createClient(client);
|
||||
}
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
|
||||
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;`;
|
||||
});
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,8 +130,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
||||
<ProjectTableHeading
|
||||
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
|
||||
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
: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>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{
|
||||
@@ -69,7 +157,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
>Create your First Project
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template v-for="project in projects" :key="project.id">
|
||||
<template v-for="project in sortedProjects" :key="project.id">
|
||||
<ProjectTableRow
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
:project="project"></ProjectTableRow>
|
||||
|
||||
@@ -1,23 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
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>
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<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
|
||||
<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 class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</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 font-semibold text-text-primary">Progress</div>
|
||||
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('client_name')">
|
||||
Client
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<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
|
||||
<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 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">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
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 { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
@@ -116,9 +116,15 @@ const showEditProjectModal = ref(false);
|
||||
{{ billableRateInfo }}
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
||||
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||
<span>Active</span>
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
||||
<template v-if="project.is_archived">
|
||||
<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
|
||||
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>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</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 font-semibold text-text-primary">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">Role</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</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 font-semibold text-text-primary">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">Description</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</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">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<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>
|
||||
<TableHeading>
|
||||
<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">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Task Name
|
||||
</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 font-semibold text-text-primary">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">Total Time</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</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">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,7 @@ const delegatedProps = computed(() => {
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
|
||||
">
|
||||
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user