Files
solidtime/resources/js/Components/Common/Project/ProjectDropdown.vue
2026-07-09 12:46:11 +02:00

185 lines
7.5 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
import { Check, Plus } from '@lucide/vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<string | null>({
default: null,
});
const open = ref(false);
const showCreateProject = ref(false);
const { projects } = useProjectsQuery();
const { clients } = useClientsQuery();
const emit = defineEmits(['update:modelValue', 'changed']);
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
// Pinned on open so rows don't re-sort while interacting; the project list itself stays reactive.
const pinnedProjectId = ref<string | null>(null);
const sortedProjects = computed(() => {
return [...projects.value].sort((a, b) => {
const aPinned = pinnedProjectId.value === a.id ? 0 : 1;
const bPinned = pinnedProjectId.value === b.id ? 0 : 1;
return aPinned - bPinned;
});
});
const shownProjects = computed(() => {
return sortedProjects.value.filter((project) => {
return project.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function handleCreateProject(projectBody: CreateProjectBody) {
const newProject = await useProjectsStore().createProject(projectBody);
if (newProject) {
model.value = newProject.id;
emit('changed');
}
return newProject;
}
async function handleCreateClient(clientBody: CreateClientBody) {
return await useClientsStore().createClient(clientBody);
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
pinnedProjectId.value = model.value;
}
});
const currentProject = computed(() => {
return projects.value.find((project) => project.id === model.value);
});
function isProjectSelected(project: Project) {
return model.value === project.id;
}
const selectedProjectName = computed(() => {
return currentProject.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return currentProject.value?.color || 'var(--theme-color-icon-default)';
});
function updateValue(project: Project) {
model.value = project.id;
emit('changed');
}
</script>
<template>
<Dropdown v-model="open" align="start">
<template #trigger>
<slot
name="trigger"
:selected-project-name="selectedProjectName"
:selected-project-color="selectedProjectColor"></slot>
</template>
<template #content>
<!-- kept open so the list stays visible during the popover close animation -->
<div>
<ComboboxRoot
:open="true"
:model-value="currentProject"
class="relative"
:ignore-filter="true"
@update:model-value="updateValue"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
v-model="searchValue"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxVirtualizer
v-slot="{ option: project }"
:options="shownProjects"
:estimate-size="32"
:text-content="(p: Project) => p.name">
<ComboboxItem
:value="project"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-project-id="project.id">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
:style="{ backgroundColor: project.color }"
class="w-3 h-3 rounded-full shrink-0"></span>
<span class="truncate">{{ project.name }}</span>
</span>
<span
v-if="isProjectSelected(project)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
</ComboboxVirtualizer>
</ComboboxViewport>
<div
v-if="canCreateProjects()"
class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground border-t border-popover-border"
@click="
open = false;
showCreateProject = true;
">
<Plus class="h-4 w-4 shrink-0" />
<span>Create new Project</span>
</div>
</ComboboxContent>
</ComboboxRoot>
</div>
</template>
</Dropdown>
<ProjectCreateModal
v-model:show="showCreateProject"
:create-project="handleCreateProject"
:create-client="handleCreateClient"
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
</template>
<style scoped></style>