add command palette

This commit is contained in:
Gregor Vostrak
2026-01-27 18:29:40 +01:00
parent 3fb75ec3d5
commit 672c243c91
24 changed files with 2292 additions and 16 deletions

View File

@@ -0,0 +1,486 @@
import type { Component } from 'vue';
import {
HomeIcon,
ClockIcon,
CalendarIcon,
ChartBarIcon,
FolderIcon,
UserCircleIcon,
UserGroupIcon,
TagIcon,
DocumentTextIcon,
CreditCardIcon,
ArrowsRightLeftIcon,
Cog6ToothIcon,
UserIcon,
PlayIcon,
StopIcon,
PlusIcon,
ArrowPathIcon,
SunIcon,
MoonIcon,
ComputerDesktopIcon,
CurrencyDollarIcon,
ClipboardDocumentListIcon,
BuildingOfficeIcon,
} from '@heroicons/vue/20/solid';
import type { Organization } from '@/types/models';
export type CommandGroup =
| 'timer'
| 'active-timer'
| 'navigation'
| 'create'
| 'theme'
| 'organization'
| 'entity';
export interface Command {
id: string;
label: string;
icon?: Component;
keywords: string[];
group: CommandGroup;
action: () => void | Promise<void>;
shortcut?: string;
permission?: () => boolean;
condition?: () => boolean;
priority: number;
}
export const GROUP_PRIORITIES: Record<CommandGroup, number> = {
timer: 1000,
'active-timer': 900,
navigation: 500,
create: 400,
organization: 300,
theme: 200,
entity: 100,
};
export function createNavigationCommands(
navigate: (route: string, params?: Record<string, string>) => void,
permissions: {
canViewProjects: () => boolean;
canViewClients: () => boolean;
canViewMembers: () => boolean;
canViewTags: () => boolean;
canViewReport: () => boolean;
canViewInvoices: () => boolean;
canManageBilling: () => boolean;
canUpdateOrganization: () => boolean;
},
features: {
isInvoicingActivated: () => boolean;
isBillingActivated: () => boolean;
},
currentTeamId: () => string
): Command[] {
return [
{
id: 'nav-dashboard',
label: 'Go to Dashboard',
icon: HomeIcon,
keywords: ['home', 'overview', 'dashboard'],
group: 'navigation',
action: () => navigate('dashboard'),
priority: GROUP_PRIORITIES.navigation + 10,
},
{
id: 'nav-time',
label: 'Go to Time',
icon: ClockIcon,
keywords: ['time', 'tracking', 'entries', 'timesheet'],
group: 'navigation',
action: () => navigate('time'),
priority: GROUP_PRIORITIES.navigation + 9,
},
{
id: 'nav-calendar',
label: 'Go to Calendar',
icon: CalendarIcon,
keywords: ['calendar', 'week', 'schedule'],
group: 'navigation',
action: () => navigate('calendar'),
priority: GROUP_PRIORITIES.navigation + 8,
},
{
id: 'nav-reporting',
label: 'Go to Reporting Overview',
icon: ChartBarIcon,
keywords: ['reports', 'analytics', 'overview', 'statistics'],
group: 'navigation',
action: () => navigate('reporting'),
priority: GROUP_PRIORITIES.navigation + 7,
},
{
id: 'nav-reporting-detailed',
label: 'Go to Reporting Detailed',
icon: ChartBarIcon,
keywords: ['detailed', 'reports', 'breakdown'],
group: 'navigation',
action: () => navigate('reporting.detailed'),
priority: GROUP_PRIORITIES.navigation + 6,
},
{
id: 'nav-reporting-shared',
label: 'Go to Shared Reports',
icon: ChartBarIcon,
keywords: ['shared', 'public', 'reports'],
group: 'navigation',
action: () => navigate('reporting.shared'),
permission: permissions.canViewReport,
priority: GROUP_PRIORITIES.navigation + 5,
},
{
id: 'nav-projects',
label: 'Go to Projects',
icon: FolderIcon,
keywords: ['projects', 'work'],
group: 'navigation',
action: () => navigate('projects'),
permission: permissions.canViewProjects,
priority: GROUP_PRIORITIES.navigation + 4,
},
{
id: 'nav-clients',
label: 'Go to Clients',
icon: UserCircleIcon,
keywords: ['clients', 'customers'],
group: 'navigation',
action: () => navigate('clients'),
permission: permissions.canViewClients,
priority: GROUP_PRIORITIES.navigation + 3,
},
{
id: 'nav-members',
label: 'Go to Members',
icon: UserGroupIcon,
keywords: ['members', 'team', 'users', 'employees'],
group: 'navigation',
action: () => navigate('members'),
permission: permissions.canViewMembers,
priority: GROUP_PRIORITIES.navigation + 2,
},
{
id: 'nav-tags',
label: 'Go to Tags',
icon: TagIcon,
keywords: ['tags', 'labels', 'categories'],
group: 'navigation',
action: () => navigate('tags'),
permission: permissions.canViewTags,
priority: GROUP_PRIORITIES.navigation + 1,
},
{
id: 'nav-invoices',
label: 'Go to Invoices',
icon: DocumentTextIcon,
keywords: ['invoices', 'billing', 'payments'],
group: 'navigation',
action: () => navigate('/invoices', {}),
permission: permissions.canViewInvoices,
condition: features.isInvoicingActivated,
priority: GROUP_PRIORITIES.navigation,
},
{
id: 'nav-billing',
label: 'Go to Billing',
icon: CreditCardIcon,
keywords: ['billing', 'subscription', 'plan'],
group: 'navigation',
action: () => navigate('/billing', {}),
permission: permissions.canManageBilling,
condition: features.isBillingActivated,
priority: GROUP_PRIORITIES.navigation - 1,
},
{
id: 'nav-import',
label: 'Go to Import / Export',
icon: ArrowsRightLeftIcon,
keywords: ['import', 'export', 'data', 'backup'],
group: 'navigation',
action: () => navigate('import'),
permission: permissions.canUpdateOrganization,
priority: GROUP_PRIORITIES.navigation - 2,
},
{
id: 'nav-settings',
label: 'Go to Settings',
icon: Cog6ToothIcon,
keywords: ['settings', 'organization', 'configuration'],
group: 'navigation',
action: () => navigate('teams.show', { team: currentTeamId() }),
permission: permissions.canUpdateOrganization,
priority: GROUP_PRIORITIES.navigation - 3,
},
{
id: 'nav-profile',
label: 'Go to Profile',
icon: UserIcon,
keywords: ['profile', 'account', 'user', 'personal'],
group: 'navigation',
action: () => navigate('profile.show'),
priority: GROUP_PRIORITIES.navigation - 4,
},
];
}
export function createTimerCommands(
timerActions: {
startTimer: () => Promise<void>;
stopTimer: () => Promise<void>;
openCreateTimeEntryModal: () => void;
continueLastEntry: () => Promise<void>;
},
conditions: {
isActive: () => boolean;
hasTimeEntries: () => boolean;
}
): Command[] {
return [
{
id: 'timer-start',
label: 'Start Timer',
icon: PlayIcon,
keywords: ['start', 'begin', 'track', 'timer'],
group: 'timer',
action: timerActions.startTimer,
condition: () => !conditions.isActive(),
priority: GROUP_PRIORITIES.timer + 10,
},
{
id: 'timer-stop',
label: 'Stop Timer',
icon: StopIcon,
keywords: ['stop', 'end', 'finish', 'timer'],
group: 'timer',
action: timerActions.stopTimer,
condition: conditions.isActive,
priority: GROUP_PRIORITIES.timer + 10,
},
{
id: 'timer-create',
label: 'Create Time Entry',
icon: PlusIcon,
keywords: ['create', 'manual', 'log', 'time', 'entry', 'new'],
group: 'timer',
action: timerActions.openCreateTimeEntryModal,
priority: GROUP_PRIORITIES.timer + 5,
},
{
id: 'timer-continue',
label: 'Continue Last Time Entry',
icon: ArrowPathIcon,
keywords: ['continue', 'repeat', 'restart', 'last', 'previous'],
group: 'timer',
action: timerActions.continueLastEntry,
condition: () => !conditions.isActive() && conditions.hasTimeEntries(),
priority: GROUP_PRIORITIES.timer + 4,
},
];
}
export function createActiveTimerCommands(
activeTimerActions: {
openProjectSelector: () => void;
openTaskSelector: () => void;
openTagsSelector: () => void;
toggleBillable: () => void;
addMinutes: (minutes: number) => void;
},
conditions: {
isActive: () => boolean;
}
): Command[] {
const minuteOptions = [5, 10, 15, 20, 25, 30, 45, 60];
const addMinutesCommands: Command[] = minuteOptions.map((minutes) => ({
id: `timer-add-${minutes}`,
label: `Add ${minutes} minutes to timer`,
icon: ClockIcon,
keywords: [`+${minutes}`, `add ${minutes}`, minutes === 60 ? 'add hour' : ''],
group: 'active-timer' as CommandGroup,
action: () => activeTimerActions.addMinutes(minutes),
condition: conditions.isActive,
priority: GROUP_PRIORITIES['active-timer'] - minutes,
}));
return [
{
id: 'timer-set-project',
label: 'Set Project',
icon: FolderIcon,
keywords: ['project', 'change project', 'select project'],
group: 'active-timer',
action: activeTimerActions.openProjectSelector,
condition: conditions.isActive,
priority: GROUP_PRIORITIES['active-timer'] + 10,
},
{
id: 'timer-set-task',
label: 'Set Task',
icon: ClipboardDocumentListIcon,
keywords: ['task', 'change task', 'select task'],
group: 'active-timer',
action: activeTimerActions.openTaskSelector,
condition: conditions.isActive,
priority: GROUP_PRIORITIES['active-timer'] + 9,
},
{
id: 'timer-set-tags',
label: 'Set Tags',
icon: TagIcon,
keywords: ['tags', 'add tags', 'labels'],
group: 'active-timer',
action: activeTimerActions.openTagsSelector,
condition: conditions.isActive,
priority: GROUP_PRIORITIES['active-timer'] + 8,
},
{
id: 'timer-toggle-billable',
label: 'Toggle Billable',
icon: CurrencyDollarIcon,
keywords: ['billable', 'non-billable', 'money'],
group: 'active-timer',
action: activeTimerActions.toggleBillable,
condition: conditions.isActive,
priority: GROUP_PRIORITIES['active-timer'] + 7,
},
...addMinutesCommands,
];
}
export function createThemeCommands(
setTheme: (theme: 'light' | 'dark' | 'system') => void
): Command[] {
return [
{
id: 'theme-light',
label: 'Switch to Light Theme',
icon: SunIcon,
keywords: ['light', 'bright', 'day', 'theme'],
group: 'theme',
action: () => setTheme('light'),
priority: GROUP_PRIORITIES.theme + 3,
},
{
id: 'theme-dark',
label: 'Switch to Dark Theme',
icon: MoonIcon,
keywords: ['dark', 'night', 'theme'],
group: 'theme',
action: () => setTheme('dark'),
priority: GROUP_PRIORITIES.theme + 2,
},
{
id: 'theme-system',
label: 'Switch to System Theme',
icon: ComputerDesktopIcon,
keywords: ['system', 'auto', 'default', 'theme'],
group: 'theme',
action: () => setTheme('system'),
priority: GROUP_PRIORITIES.theme + 1,
},
];
}
export function createCreateCommands(
createActions: {
openProjectModal: () => void;
openClientModal: () => void;
openTaskModal: () => void;
openTagModal: () => void;
openInviteModal: () => void;
},
permissions: {
canCreateProjects: () => boolean;
canCreateClients: () => boolean;
canCreateTasks: () => boolean;
canCreateTags: () => boolean;
canCreateInvitations: () => boolean;
}
): Command[] {
return [
{
id: 'create-project',
label: 'Create Project',
icon: FolderIcon,
keywords: ['new project', 'add project', 'create'],
group: 'create',
action: createActions.openProjectModal,
permission: permissions.canCreateProjects,
priority: GROUP_PRIORITIES.create + 5,
},
{
id: 'create-client',
label: 'Create Client',
icon: UserCircleIcon,
keywords: ['new client', 'add client', 'create'],
group: 'create',
action: createActions.openClientModal,
permission: permissions.canCreateClients,
priority: GROUP_PRIORITIES.create + 4,
},
{
id: 'create-task',
label: 'Create Task',
icon: ClipboardDocumentListIcon,
keywords: ['new task', 'add task', 'create'],
group: 'create',
action: createActions.openTaskModal,
permission: permissions.canCreateTasks,
priority: GROUP_PRIORITIES.create + 3,
},
{
id: 'create-tag',
label: 'Create Tag',
icon: TagIcon,
keywords: ['new tag', 'add tag', 'create'],
group: 'create',
action: createActions.openTagModal,
permission: permissions.canCreateTags,
priority: GROUP_PRIORITIES.create + 2,
},
{
id: 'create-invite',
label: 'Invite Member',
icon: UserGroupIcon,
keywords: ['invite', 'add member', 'team'],
group: 'create',
action: createActions.openInviteModal,
permission: permissions.canCreateInvitations,
priority: GROUP_PRIORITIES.create + 1,
},
];
}
export function createOrganizationCommands(
organizations: Organization[],
currentOrgId: string,
switchOrganization: (orgId: string) => void
): Command[] {
if (organizations.length <= 1) return [];
return organizations
.filter((org) => org.id !== currentOrgId)
.map((org) => ({
id: `org-switch-${org.id}`,
label: `Switch to ${org.name}`,
icon: BuildingOfficeIcon,
keywords: ['switch', 'organization', 'workspace', org.name.toLowerCase()],
group: 'organization' as CommandGroup,
action: () => switchOrganization(org.id),
priority: GROUP_PRIORITIES.organization + 1,
}));
}
export function scoreEntity(name: string, query: string, baseScore: number): number {
const normalizedName = name.toLowerCase();
const normalizedQuery = query.toLowerCase().trim();
if (normalizedName === normalizedQuery) return baseScore + 50;
if (normalizedName.startsWith(normalizedQuery)) return baseScore + 30;
if (normalizedName.includes(normalizedQuery)) return baseScore + 10;
return baseScore;
}

View File

@@ -0,0 +1,552 @@
import { ref, computed } from 'vue';
import { router } from '@inertiajs/vue3';
import { storeToRefs } from 'pinia';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { themeSetting, type themeOption } from '@/utils/theme';
import {
canViewProjects,
canViewClients,
canViewMembers,
canViewTags,
canViewReport,
canViewInvoices,
canManageBilling,
canUpdateOrganization,
canCreateProjects,
canCreateClients,
canCreateTasks,
canCreateTags,
canCreateInvitations,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useMembersQuery } from '@/utils/useMembersQuery';
import {
createNavigationCommands,
createTimerCommands,
createActiveTimerCommands,
createThemeCommands,
createCreateCommands,
createOrganizationCommands,
scoreEntity,
GROUP_PRIORITIES,
type Command,
type CommandGroup as CommandGroupType,
} from '@/utils/commandPaletteCommands';
import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import { switchOrganization } from '@/utils/useOrganization';
import type {
CommandPaletteGroup,
EntitySearchResult,
} from '@/packages/ui/src/CommandPalette/CommandPaletteTypes';
import type { Project, Client, Task, Tag, Member } from '@/packages/api/src';
import {
FolderIcon,
UserCircleIcon,
TagIcon,
UserGroupIcon,
ClipboardDocumentListIcon,
} from '@heroicons/vue/20/solid';
// Global state (singleton pattern - shared across all useCommandPalette() calls)
const isOpen = ref(false);
const searchTerm = ref('');
// Modal states for create actions
const showCreateProjectModal = ref(false);
const showCreateClientModal = ref(false);
const showCreateTaskModal = ref(false);
const showCreateTagModal = ref(false);
const showInviteMemberModal = ref(false);
const showCreateTimeEntryModal = ref(false);
// Active timer selector states
const showProjectSelector = ref(false);
const showTaskSelector = ref(false);
const showTagsSelector = ref(false);
// Group display order and headings
const GROUP_CONFIG: { id: CommandGroupType; heading: string }[] = [
{ id: 'timer', heading: 'Timer' },
{ id: 'active-timer', heading: 'Active Timer' },
{ id: 'navigation', heading: 'Navigation' },
{ id: 'create', heading: 'Create' },
{ id: 'organization', heading: 'Organization' },
{ id: 'theme', heading: 'Theme' },
];
// Entity badge classes
const ENTITY_BADGE_CLASSES: Record<string, string> = {
project: 'bg-violet-500/20 text-violet-500',
client: 'bg-blue-500/20 text-blue-500',
task: 'bg-gray-500/20 text-gray-400',
tag: 'bg-amber-500/20 text-amber-500',
member: 'bg-green-500/20 text-green-500',
};
// Entity icons
const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
project: FolderIcon,
client: UserCircleIcon,
task: ClipboardDocumentListIcon,
tag: TagIcon,
member: UserGroupIcon,
};
export function useCommandPalette() {
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const { setActiveState, updateTimer } = currentTimeEntryStore;
// Data queries (consolidated here - single source of truth)
const timeEntriesQuery = useTimeEntriesInfiniteQuery();
const { projects } = useProjectsQuery();
const { clients } = useClientsQuery();
const { tasks } = useTasksQuery();
const { tags } = useTagsQuery();
const { members } = useMembersQuery();
const page = usePage<{
auth: {
user: User & {
all_teams: Organization[];
current_team_id: string;
};
};
}>();
const getCurrentTeamId = () => page.props.auth.user.current_team?.id ?? '';
const allOrganizations = computed(() => page.props.auth.user.all_teams || []);
const currentOrgId = computed(() => page.props.auth.user.current_team_id || '');
const lastTimeEntry = computed(() => {
const pages = timeEntriesQuery.data.value?.pages;
if (!pages || pages.length === 0) return null;
const firstPage = pages[0];
if (!firstPage?.data || firstPage.data.length === 0) return null;
return firstPage.data[0];
});
const hasTimeEntries = computed(() => lastTimeEntry.value !== null);
// Helper to close palette
function closePaletteAfterAction() {
isOpen.value = false;
}
// Navigation helper
function navigate(routeName: string, params?: Record<string, string>) {
closePaletteAfterAction();
if (routeName.startsWith('/')) {
router.visit(routeName);
} else {
router.visit(route(routeName, params));
}
}
// Theme helper
function setTheme(theme: themeOption) {
themeSetting.value = theme;
closePaletteAfterAction();
}
// Timer actions
async function startTimer() {
closePaletteAfterAction();
await setActiveState(true);
}
async function stopTimer() {
closePaletteAfterAction();
await setActiveState(false);
}
function openCreateTimeEntryModal() {
closePaletteAfterAction();
showCreateTimeEntryModal.value = true;
}
async function continueLastEntry() {
if (!lastTimeEntry.value) return;
closePaletteAfterAction();
currentTimeEntry.value.description = lastTimeEntry.value.description;
currentTimeEntry.value.project_id = lastTimeEntry.value.project_id;
currentTimeEntry.value.task_id = lastTimeEntry.value.task_id;
currentTimeEntry.value.tags = lastTimeEntry.value.tags;
currentTimeEntry.value.billable = lastTimeEntry.value.billable;
currentTimeEntry.value.start = getDayJsInstance()().utc().format();
await setActiveState(true);
}
// Active timer actions
function openProjectSelector() {
closePaletteAfterAction();
showProjectSelector.value = true;
}
function openTaskSelector() {
closePaletteAfterAction();
showTaskSelector.value = true;
}
function openTagsSelector() {
closePaletteAfterAction();
showTagsSelector.value = true;
}
async function toggleBillable() {
closePaletteAfterAction();
currentTimeEntry.value.billable = !currentTimeEntry.value.billable;
await updateTimer();
}
async function addMinutes(minutes: number) {
closePaletteAfterAction();
currentTimeEntry.value.start = getDayJsInstance()(currentTimeEntry.value.start)
.subtract(minutes, 'minutes')
.utc()
.format();
await updateTimer();
}
// Create actions
function openCreateProjectModal() {
closePaletteAfterAction();
showCreateProjectModal.value = true;
}
function openCreateClientModal() {
closePaletteAfterAction();
showCreateClientModal.value = true;
}
function openCreateTaskModal() {
closePaletteAfterAction();
showCreateTaskModal.value = true;
}
function openCreateTagModal() {
closePaletteAfterAction();
showCreateTagModal.value = true;
}
function openInviteMemberModal() {
closePaletteAfterAction();
showInviteMemberModal.value = true;
}
// Organization switch action
function handleSwitchOrganization(orgId: string) {
closePaletteAfterAction();
switchOrganization(orgId);
}
// Build all internal commands
const navigationCommands = computed(() =>
createNavigationCommands(
navigate,
{
canViewProjects,
canViewClients,
canViewMembers,
canViewTags,
canViewReport,
canViewInvoices,
canManageBilling,
canUpdateOrganization,
},
{
isInvoicingActivated,
isBillingActivated,
},
getCurrentTeamId
)
);
const timerCommands = computed(() =>
createTimerCommands(
{
startTimer,
stopTimer,
openCreateTimeEntryModal,
continueLastEntry,
},
{
isActive: () => isActive.value,
hasTimeEntries: () => hasTimeEntries.value,
}
)
);
const activeTimerCommands = computed(() =>
createActiveTimerCommands(
{
openProjectSelector,
openTaskSelector,
openTagsSelector,
toggleBillable,
addMinutes,
},
{
isActive: () => isActive.value,
}
)
);
const themeCommands = computed(() => createThemeCommands(setTheme));
const createCommands = computed(() =>
createCreateCommands(
{
openProjectModal: openCreateProjectModal,
openClientModal: openCreateClientModal,
openTaskModal: openCreateTaskModal,
openTagModal: openCreateTagModal,
openInviteModal: openInviteMemberModal,
},
{
canCreateProjects,
canCreateClients,
canCreateTasks,
canCreateTags,
canCreateInvitations,
}
)
);
const organizationCommands = computed(() =>
createOrganizationCommands(
allOrganizations.value,
currentOrgId.value,
handleSwitchOrganization
)
);
// Internal commands grouped by type
const commandsByGroup = computed<Record<string, Command[]>>(() => {
const allCommands: Command[] = [
...timerCommands.value,
...activeTimerCommands.value,
...navigationCommands.value,
...createCommands.value,
...organizationCommands.value,
...themeCommands.value,
];
const grouped: Record<string, Command[]> = {};
for (const config of GROUP_CONFIG) {
grouped[config.id] = [];
}
for (const cmd of allCommands) {
if (cmd.permission && !cmd.permission()) continue;
if (cmd.condition && !cmd.condition()) continue;
if (grouped[cmd.group]) {
grouped[cmd.group].push(cmd);
}
}
return grouped;
});
// Map internal commands to UI-friendly CommandPaletteGroup[]
const groups = computed<CommandPaletteGroup[]>(() =>
GROUP_CONFIG.map((config) => ({
id: config.id,
heading: config.heading,
commands: (commandsByGroup.value[config.id] ?? []).map((cmd) => ({
id: cmd.id,
label: cmd.label,
icon: cmd.icon,
keywords: cmd.keywords,
action: cmd.action,
shortcut: cmd.shortcut,
})),
}))
);
// Entity search results (moved from old CommandPalette.vue)
const entityResults = computed<EntitySearchResult[]>(() => {
const query = searchTerm.value.toLowerCase().trim();
if (!query || query.length < 2) return [];
const results: EntitySearchResult[] = [];
const maxPerType = 5;
if (canViewProjects()) {
const matching = projects.value
.filter((p: Project) => p.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(p: Project): EntitySearchResult => ({
id: `entity-project-${p.id}`,
label: p.name,
icon: ENTITY_ICONS.project,
keywords: ['project'],
action: () => {
closePaletteAfterAction();
router.visit(route('projects.show', { project: p.id }));
},
entityType: 'project',
color: p.color,
badgeClass: ENTITY_BADGE_CLASSES.project,
})
);
results.push(...matching);
}
if (canViewClients()) {
const matching = clients.value
.filter((c: Client) => c.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(c: Client): EntitySearchResult => ({
id: `entity-client-${c.id}`,
label: c.name,
icon: ENTITY_ICONS.client,
keywords: ['client'],
action: () => {
closePaletteAfterAction();
router.visit(route('clients'));
},
entityType: 'client',
badgeClass: ENTITY_BADGE_CLASSES.client,
})
);
results.push(...matching);
}
if (canViewProjects()) {
const matching = tasks.value
.filter((t: Task) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(t: Task): EntitySearchResult => ({
id: `entity-task-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.task,
keywords: ['task'],
action: () => {
closePaletteAfterAction();
if (t.project_id) {
router.visit(route('projects.show', { project: t.project_id }));
}
},
entityType: 'task',
badgeClass: ENTITY_BADGE_CLASSES.task,
})
);
results.push(...matching);
}
if (canViewTags()) {
const matching = tags.value
.filter((t: Tag) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(t: Tag): EntitySearchResult => ({
id: `entity-tag-${t.id}`,
label: t.name,
icon: ENTITY_ICONS.tag,
keywords: ['tag'],
action: () => {
closePaletteAfterAction();
router.visit(route('tags'));
},
entityType: 'tag',
badgeClass: ENTITY_BADGE_CLASSES.tag,
})
);
results.push(...matching);
}
if (canViewMembers()) {
const matching = members.value
.filter((m: Member) => m.name.toLowerCase().includes(query))
.slice(0, maxPerType)
.map(
(m: Member): EntitySearchResult => ({
id: `entity-member-${m.id}`,
label: m.name,
icon: ENTITY_ICONS.member,
keywords: ['member'],
action: () => {
closePaletteAfterAction();
router.visit(route('members'));
},
entityType: 'member',
badgeClass: ENTITY_BADGE_CLASSES.member,
})
);
results.push(...matching);
}
return results.sort(
(a, b) =>
scoreEntity(b.label, query, GROUP_PRIORITIES.entity) -
scoreEntity(a.label, query, GROUP_PRIORITIES.entity)
);
});
// Open/close
function openPalette() {
isOpen.value = true;
}
function closePalette() {
isOpen.value = false;
}
function togglePalette() {
if (isOpen.value) {
closePalette();
} else {
openPalette();
}
}
return {
// State
isOpen,
searchTerm,
// Modal states
showCreateProjectModal,
showCreateClientModal,
showCreateTaskModal,
showCreateTagModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,
showTaskSelector,
showTagsSelector,
// UI data (for CommandPalette component props)
groups,
entityResults,
// Query data (for Provider modals)
projects,
clients,
tasks,
tags,
// Computed
isActive,
currentTimeEntry,
// Actions
openPalette,
closePalette,
togglePalette,
updateTimer,
};
}