mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 12:12:15 +01:00
add command palette
This commit is contained in:
486
resources/js/utils/commandPaletteCommands.ts
Normal file
486
resources/js/utils/commandPaletteCommands.ts
Normal 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;
|
||||
}
|
||||
552
resources/js/utils/useCommandPalette.ts
Normal file
552
resources/js/utils/useCommandPalette.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user