mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 01:03:16 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
@@ -17,6 +17,14 @@ export function isInvoicingActivated() {
|
||||
return page.props.has_invoicing_extension;
|
||||
}
|
||||
|
||||
export function isGoalsExtensionActivated() {
|
||||
const page = usePage<{
|
||||
has_goals_extension: boolean;
|
||||
}>();
|
||||
|
||||
return page.props.has_goals_extension;
|
||||
}
|
||||
|
||||
export function isInTrial() {
|
||||
const page = usePage<{
|
||||
billing: {
|
||||
@@ -49,6 +57,26 @@ export function isBlocked() {
|
||||
return page.props.billing.is_blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum number of goals the organization can have, null means unlimited.
|
||||
* Archived goals count towards the limit as well.
|
||||
*/
|
||||
export function goalLimit(): number | null {
|
||||
const page = usePage<{
|
||||
billing: {
|
||||
goal_limit: number | null;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
return page.props.billing?.goal_limit ?? null;
|
||||
}
|
||||
|
||||
export function canCreateMoreGoals(goalCount: number): boolean {
|
||||
const limit = goalLimit();
|
||||
|
||||
return limit === null || goalCount < limit;
|
||||
}
|
||||
|
||||
export function isFreePlan() {
|
||||
return !hasActiveSubscription() && !isInTrial();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ClipboardDocumentListIcon,
|
||||
BuildingOfficeIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { Target } from '@lucide/vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import type { Organization } from '@/types/models';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function createNavigationCommands(
|
||||
canViewTags: () => boolean;
|
||||
canViewReport: () => boolean;
|
||||
canViewInvoices: () => boolean;
|
||||
canViewGoals: () => boolean;
|
||||
canManageBilling: () => boolean;
|
||||
canUpdateOrganization: () => boolean;
|
||||
},
|
||||
@@ -126,6 +128,16 @@ export function createNavigationCommands(
|
||||
permission: permissions.canViewReport,
|
||||
priority: GROUP_PRIORITIES.navigation + 5,
|
||||
},
|
||||
{
|
||||
id: 'nav-goals',
|
||||
label: 'Go to Goals',
|
||||
icon: Target,
|
||||
keywords: ['goals', 'targets', 'habits', 'progress'],
|
||||
group: 'navigation',
|
||||
action: () => navigate('goals'),
|
||||
permission: permissions.canViewGoals,
|
||||
priority: GROUP_PRIORITIES.navigation + 4.5,
|
||||
},
|
||||
{
|
||||
id: 'nav-projects',
|
||||
label: 'Go to Projects',
|
||||
@@ -385,6 +397,7 @@ export function createCreateCommands(
|
||||
openClientModal: () => void;
|
||||
openTaskModal: () => void;
|
||||
openTagModal: () => void;
|
||||
openGoalModal: () => void;
|
||||
openInviteModal: () => void;
|
||||
},
|
||||
permissions: {
|
||||
@@ -392,6 +405,7 @@ export function createCreateCommands(
|
||||
canCreateClients: () => boolean;
|
||||
canCreateTasks: () => boolean;
|
||||
canCreateTags: () => boolean;
|
||||
canCreateGoals: () => boolean;
|
||||
canCreateInvitations: () => boolean;
|
||||
}
|
||||
): Command[] {
|
||||
@@ -436,6 +450,16 @@ export function createCreateCommands(
|
||||
permission: permissions.canCreateTags,
|
||||
priority: GROUP_PRIORITIES.create + 2,
|
||||
},
|
||||
{
|
||||
id: 'create-goal',
|
||||
label: 'Create Goal',
|
||||
icon: Target,
|
||||
keywords: ['new goal', 'add goal', 'create', 'target'],
|
||||
group: 'create',
|
||||
action: createActions.openGoalModal,
|
||||
permission: permissions.canCreateGoals,
|
||||
priority: GROUP_PRIORITIES.create + 1.5,
|
||||
},
|
||||
{
|
||||
id: 'create-invite',
|
||||
label: 'Invite Member',
|
||||
|
||||
63
resources/js/utils/goals.ts
Normal file
63
resources/js/utils/goals.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Goal, GoalComparison, GoalPeriod, GoalStatus } from '@/packages/api/src';
|
||||
import { canDeleteOrganizationGoals, canUpdateOrganizationGoals } from '@/utils/permissions';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
|
||||
export const goalComparisonOptions: { value: GoalComparison; label: string }[] = [
|
||||
{ value: 'at_least', label: 'At least' },
|
||||
{ value: 'less_than', label: 'Less than' },
|
||||
];
|
||||
|
||||
export const goalPeriodOptions: { value: GoalPeriod; label: string }[] = [
|
||||
{ value: 'day', label: 'per day' },
|
||||
{ value: 'week', label: 'per week' },
|
||||
{ value: 'month', label: 'per month' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Who a goal is for. The three answers map to the goal type and the member of the goal:
|
||||
* me = a personal goal for yourself, other = an organization goal for one member,
|
||||
* organization = an organization goal that counts every member.
|
||||
*/
|
||||
export type GoalTarget = 'me' | 'other' | 'organization';
|
||||
|
||||
export const goalTargetOptions: { value: GoalTarget; label: string }[] = [
|
||||
{ value: 'me', label: 'For me' },
|
||||
{ value: 'other', label: 'For someone else' },
|
||||
{ value: 'organization', label: 'Organization goal' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mirrors the access rules on the server: a personal goal belongs to its member, an organization goal
|
||||
* to the members that may manage goals. The member an organization goal is for never manages it.
|
||||
*/
|
||||
export function canManageGoal(goal: Goal): boolean {
|
||||
if (goal.type === 'personal') {
|
||||
return goal.member_id === getCurrentMembershipId();
|
||||
}
|
||||
return canUpdateOrganizationGoals() || canDeleteOrganizationGoals();
|
||||
}
|
||||
|
||||
export function goalTypeLabel(goal: Goal): string {
|
||||
return goal.type === 'personal' ? 'Personal goal' : 'Organization goal';
|
||||
}
|
||||
|
||||
export function goalComparisonLabel(comparison: GoalComparison): string {
|
||||
return goalComparisonOptions.find((option) => option.value === comparison)?.label ?? comparison;
|
||||
}
|
||||
|
||||
export function goalStatusLabel(status: GoalStatus): string {
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return 'In progress';
|
||||
case 'achieved':
|
||||
return 'Achieved';
|
||||
case 'on_track':
|
||||
return 'On track';
|
||||
case 'exceeded':
|
||||
return 'Exceeded';
|
||||
}
|
||||
}
|
||||
|
||||
export function goalStatusIsPositive(status: GoalStatus): boolean {
|
||||
return status === 'achieved' || status === 'on_track';
|
||||
}
|
||||
@@ -132,3 +132,43 @@ export function canViewInvoices() {
|
||||
export function canCreateReports() {
|
||||
return currentUserHasPermission('reports:create');
|
||||
}
|
||||
|
||||
// Goal permissions are split by the type of goal they reach: `:own` covers the goals a member sets for
|
||||
// themselves, `:organization-type` the organization goals of the team goals extension. The helpers without a
|
||||
// suffix answer whether the member reaches at least one of the two, which is what the UI entry points need.
|
||||
export function canViewOwnGoals() {
|
||||
return currentUserHasPermission('goals:view:own');
|
||||
}
|
||||
export function canViewOrganizationGoals() {
|
||||
return currentUserHasPermission('goals:view:organization-type');
|
||||
}
|
||||
export function canViewGoals() {
|
||||
return canViewOwnGoals() || canViewOrganizationGoals();
|
||||
}
|
||||
export function canCreateOwnGoals() {
|
||||
return currentUserHasPermission('goals:create:own');
|
||||
}
|
||||
export function canCreateOrganizationGoals() {
|
||||
return currentUserHasPermission('goals:create:organization-type');
|
||||
}
|
||||
export function canCreateGoals() {
|
||||
return canCreateOwnGoals() || canCreateOrganizationGoals();
|
||||
}
|
||||
export function canUpdateOwnGoals() {
|
||||
return currentUserHasPermission('goals:update:own');
|
||||
}
|
||||
export function canUpdateOrganizationGoals() {
|
||||
return currentUserHasPermission('goals:update:organization-type');
|
||||
}
|
||||
export function canUpdateGoals() {
|
||||
return canUpdateOwnGoals() || canUpdateOrganizationGoals();
|
||||
}
|
||||
export function canDeleteOwnGoals() {
|
||||
return currentUserHasPermission('goals:delete:own');
|
||||
}
|
||||
export function canDeleteOrganizationGoals() {
|
||||
return currentUserHasPermission('goals:delete:organization-type');
|
||||
}
|
||||
export function canDeleteGoals() {
|
||||
return canDeleteOwnGoals() || canDeleteOrganizationGoals();
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
canViewTags,
|
||||
canViewReport,
|
||||
canViewInvoices,
|
||||
canViewGoals,
|
||||
canManageBilling,
|
||||
canUpdateOrganization,
|
||||
canCreateProjects,
|
||||
canCreateClients,
|
||||
canCreateTasks,
|
||||
canCreateTags,
|
||||
canCreateGoals,
|
||||
canCreateInvitations,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
@@ -63,6 +65,7 @@ const showCreateProjectModal = ref(false);
|
||||
const showCreateClientModal = ref(false);
|
||||
const showCreateTaskModal = ref(false);
|
||||
const showCreateTagModal = ref(false);
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showInviteMemberModal = ref(false);
|
||||
const showCreateTimeEntryModal = ref(false);
|
||||
|
||||
@@ -237,6 +240,11 @@ export function useCommandPalette() {
|
||||
showCreateTagModal.value = true;
|
||||
}
|
||||
|
||||
function openCreateGoalModal() {
|
||||
closePaletteAfterAction();
|
||||
showCreateGoalModal.value = true;
|
||||
}
|
||||
|
||||
function openInviteMemberModal() {
|
||||
closePaletteAfterAction();
|
||||
showInviteMemberModal.value = true;
|
||||
@@ -259,6 +267,7 @@ export function useCommandPalette() {
|
||||
canViewTags,
|
||||
canViewReport,
|
||||
canViewInvoices,
|
||||
canViewGoals,
|
||||
canManageBilling,
|
||||
canUpdateOrganization,
|
||||
},
|
||||
@@ -309,6 +318,7 @@ export function useCommandPalette() {
|
||||
openClientModal: openCreateClientModal,
|
||||
openTaskModal: openCreateTaskModal,
|
||||
openTagModal: openCreateTagModal,
|
||||
openGoalModal: openCreateGoalModal,
|
||||
openInviteModal: openInviteMemberModal,
|
||||
},
|
||||
{
|
||||
@@ -316,6 +326,7 @@ export function useCommandPalette() {
|
||||
canCreateClients,
|
||||
canCreateTasks,
|
||||
canCreateTags,
|
||||
canCreateGoals,
|
||||
canCreateInvitations,
|
||||
}
|
||||
)
|
||||
@@ -513,6 +524,7 @@ export function useCommandPalette() {
|
||||
showCreateClientModal,
|
||||
showCreateTaskModal,
|
||||
showCreateTagModal,
|
||||
showCreateGoalModal,
|
||||
showInviteMemberModal,
|
||||
showCreateTimeEntryModal,
|
||||
showProjectSelector,
|
||||
|
||||
@@ -205,6 +205,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
stopLiveTimer();
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
|
||||
async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) {
|
||||
@@ -223,6 +224,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
startLiveTimer();
|
||||
await startTimer();
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
|
||||
async function updateTimer() {
|
||||
@@ -302,6 +304,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
await stopTimer();
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
126
resources/js/utils/useGoals.ts
Normal file
126
resources/js/utils/useGoals.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { CreateGoalBody, Goal, UpdateGoalBody } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { isApiValidationError } from '@/utils/apiValidation';
|
||||
|
||||
export const useGoalsStore = defineStore('goals', () => {
|
||||
const { addNotification, handleApiRequestNotifications } = useNotificationsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Like handleApiRequestNotifications, but a 422 response is rethrown as the AxiosError
|
||||
* without a toast, so that the goal form can show the errors next to its fields.
|
||||
*/
|
||||
async function handleGoalFormRequest<T>(
|
||||
apiRequest: () => Promise<T>,
|
||||
successMessage: string,
|
||||
errorMessage: string
|
||||
): Promise<T | undefined> {
|
||||
let validationError: unknown = null;
|
||||
const response = await handleApiRequestNotifications(
|
||||
async () => {
|
||||
try {
|
||||
return await apiRequest();
|
||||
} catch (error) {
|
||||
if (isApiValidationError(error)) {
|
||||
validationError = error;
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
if (validationError !== null) {
|
||||
throw validationError;
|
||||
}
|
||||
addNotification('success', successMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function createGoal(goalBody: CreateGoalBody): Promise<Goal | undefined> {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
const response = await handleGoalFormRequest(
|
||||
() =>
|
||||
api.createGoal(goalBody, {
|
||||
params: {
|
||||
organization: organization,
|
||||
},
|
||||
}),
|
||||
'Goal created successfully',
|
||||
'Failed to create goal'
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
return response?.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGoal(goalId: string, goalBody: UpdateGoalBody): Promise<Goal | undefined> {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
const response = await handleGoalFormRequest(
|
||||
() =>
|
||||
api.updateGoal(goalBody, {
|
||||
params: {
|
||||
organization: organization,
|
||||
goal: goalId,
|
||||
},
|
||||
}),
|
||||
'Goal updated successfully',
|
||||
'Failed to update goal'
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
return response?.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived goals are hidden from the goal list by default, archiving is not deleting.
|
||||
*/
|
||||
async function setGoalArchived(goalId: string, isArchived: boolean): Promise<Goal | undefined> {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateGoal(
|
||||
{ is_archived: isArchived },
|
||||
{
|
||||
params: {
|
||||
organization: organization,
|
||||
goal: goalId,
|
||||
},
|
||||
}
|
||||
),
|
||||
isArchived ? 'Goal archived successfully' : 'Goal unarchived successfully',
|
||||
isArchived ? 'Failed to archive goal' : 'Failed to unarchive goal'
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
return response?.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGoal(goalId: string) {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteGoal(undefined, {
|
||||
params: {
|
||||
organization: organization,
|
||||
goal: goalId,
|
||||
},
|
||||
}),
|
||||
'Goal deleted successfully',
|
||||
'Failed to delete goal'
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
}
|
||||
|
||||
return { createGoal, updateGoal, setGoalArchived, deleteGoal };
|
||||
});
|
||||
50
resources/js/utils/useGoalsQuery.ts
Normal file
50
resources/js/utils/useGoalsQuery.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import { computed } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
|
||||
export type GoalArchivedFilter = 'true' | 'false' | 'all';
|
||||
|
||||
export async function fetchAllGoals(
|
||||
organizationId: string,
|
||||
archived: GoalArchivedFilter = 'false'
|
||||
): Promise<Goal[]> {
|
||||
return fetchAllPages((page) =>
|
||||
api.getGoals({
|
||||
params: { organization: organizationId },
|
||||
queries: { page, archived },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
|
||||
queryFn: async () => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (!organizationId) throw new Error('No organization');
|
||||
const data = await fetchAllGoals(organizationId, archived);
|
||||
return { data };
|
||||
},
|
||||
enabled: () => !!getCurrentOrganizationId(),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
// Progress is computed server side, keep it fresh while the page is open
|
||||
refetchInterval: 1000 * 60,
|
||||
});
|
||||
|
||||
const goals = computed<Goal[]>(() => query.data.value?.data ?? []);
|
||||
|
||||
const invalidateGoals = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
};
|
||||
|
||||
return {
|
||||
...query,
|
||||
goals,
|
||||
invalidateGoals,
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export async function fetchAllMembers(organizationId: string): Promise<Member[]>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMembersQuery() {
|
||||
export function useMembersQuery(options: { enabled?: () => boolean } = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
@@ -25,7 +25,7 @@ export function useMembersQuery() {
|
||||
const data = await fetchAllMembers(organizationId);
|
||||
return { data };
|
||||
},
|
||||
enabled: () => !!getCurrentOrganizationId(),
|
||||
enabled: () => !!getCurrentOrganizationId() && (options.enabled?.() ?? true),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
|
||||
21
resources/js/utils/useTimezonesQuery.ts
Normal file
21
resources/js/utils/useTimezonesQuery.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useTimezonesQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: ['timezones'],
|
||||
queryFn: () => api.getTimezones(),
|
||||
// The list of timezones does not change while the app is open
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const timezones = computed<string[]>(
|
||||
() => query.data.value?.map((timezone) => timezone.key) ?? []
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
timezones,
|
||||
};
|
||||
}
|
||||
11
resources/js/utils/weekdays.ts
Normal file
11
resources/js/utils/weekdays.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Weekday } from '@/packages/api/src';
|
||||
|
||||
export const weekdayOptions: { value: Weekday; label: string }[] = [
|
||||
{ value: 'monday', label: 'Monday' },
|
||||
{ value: 'tuesday', label: 'Tuesday' },
|
||||
{ value: 'wednesday', label: 'Wednesday' },
|
||||
{ value: 'thursday', label: 'Thursday' },
|
||||
{ value: 'friday', label: 'Friday' },
|
||||
{ value: 'saturday', label: 'Saturday' },
|
||||
{ value: 'sunday', label: 'Sunday' },
|
||||
];
|
||||
Reference in New Issue
Block a user