mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-16 17:53:18 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
@@ -28,6 +28,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
|
||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
|
||||
@@ -51,6 +52,7 @@ const {
|
||||
showCreateClientModal,
|
||||
showCreateTaskModal,
|
||||
showCreateTagModal,
|
||||
showCreateGoalModal,
|
||||
showInviteMemberModal,
|
||||
showCreateTimeEntryModal,
|
||||
showProjectSelector,
|
||||
@@ -181,6 +183,9 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
<!-- Tag Create Modal -->
|
||||
<TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" />
|
||||
|
||||
<!-- Goal Create Modal -->
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
||||
|
||||
<!-- Member Invite Modal -->
|
||||
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||
|
||||
|
||||
83
resources/js/Components/Common/Goal/GoalCreateModal.vue
Normal file
83
resources/js/Components/Common/Goal/GoalCreateModal.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import GoalFormFields from '@/Components/Common/Goal/GoalFormFields.vue';
|
||||
import {
|
||||
type GoalFormErrors,
|
||||
emptyGoalFormData,
|
||||
goalFormDataToCreateBody,
|
||||
goalFormErrorsFromApi,
|
||||
validateGoalFormData,
|
||||
} from '@/Components/Common/Goal/goalForm';
|
||||
import { useGoalsStore } from '@/utils/useGoals';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { getApiValidationFieldErrors, isApiValidationError } from '@/utils/apiValidation';
|
||||
|
||||
const { createGoal } = useGoalsStore();
|
||||
const { addNotification } = useNotificationsStore();
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
const saving = ref(false);
|
||||
const goal = ref(emptyGoalFormData());
|
||||
const errors = ref<GoalFormErrors>({});
|
||||
|
||||
watch(show, (isShown) => {
|
||||
if (isShown) {
|
||||
goal.value = emptyGoalFormData();
|
||||
errors.value = {};
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
errors.value = validateGoalFormData(goal.value);
|
||||
if (Object.keys(errors.value).length > 0) {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const created = await createGoal(goalFormDataToCreateBody(goal.value));
|
||||
if (created) {
|
||||
show.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
// Other errors were already shown as a notification by the store
|
||||
if (isApiValidationError(error)) {
|
||||
const apiErrors = goalFormErrorsFromApi(getApiValidationFieldErrors(error), goal.value);
|
||||
errors.value = apiErrors.errors;
|
||||
if (apiErrors.unmapped.length > 0) {
|
||||
addNotification('error', 'Failed to create goal', apiErrors.unmapped[0]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Goal </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<GoalFormFields v-model="goal" :errors="errors" @submit="submit"></GoalFormFields>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit">
|
||||
Create Goal
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
206
resources/js/Components/Common/Goal/GoalEditModal.vue
Normal file
206
resources/js/Components/Common/Goal/GoalEditModal.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import GoalFormFields from '@/Components/Common/Goal/GoalFormFields.vue';
|
||||
import {
|
||||
type GoalFormErrors,
|
||||
type FilterIdKey,
|
||||
type GoalFormData,
|
||||
getUnavailableGoalFilters,
|
||||
goalFormDataToUpdateBody,
|
||||
goalFormErrorsFromApi,
|
||||
goalToFormData,
|
||||
type KnownFilterIds,
|
||||
type UnavailableGoalFilter,
|
||||
validateGoalFormData,
|
||||
withoutUnavailableGoalFilters,
|
||||
} from '@/Components/Common/Goal/goalForm';
|
||||
import { useGoalsStore } from '@/utils/useGoals';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { getApiValidationFieldErrors, isApiValidationError } from '@/utils/apiValidation';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { canViewMembers } from '@/utils/permissions';
|
||||
|
||||
const { updateGoal } = useGoalsStore();
|
||||
const { addNotification } = useNotificationsStore();
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
goal: Goal;
|
||||
}>();
|
||||
|
||||
const projectsQuery = useProjectsQuery();
|
||||
const tasksQuery = useTasksQuery();
|
||||
const clientsQuery = useClientsQuery();
|
||||
const tagsQuery = useTagsQuery();
|
||||
const membersQuery = canViewMembers() ? useMembersQuery() : null;
|
||||
|
||||
function idSet(entities: { id: string }[] | undefined): Set<string> | null {
|
||||
return entities === undefined ? null : new Set(entities.map((entity) => entity.id));
|
||||
}
|
||||
|
||||
const filterQueries = {
|
||||
project_ids: projectsQuery,
|
||||
task_ids: tasksQuery,
|
||||
client_ids: clientsQuery,
|
||||
tag_ids: tagsQuery,
|
||||
member_ids: membersQuery,
|
||||
};
|
||||
|
||||
const knownFilterIds = computed<KnownFilterIds>(() => ({
|
||||
project_ids: projectsQuery.isSuccess.value ? idSet(projectsQuery.data.value?.data) : null,
|
||||
task_ids: tasksQuery.isSuccess.value ? idSet(tasksQuery.data.value?.data) : null,
|
||||
client_ids: clientsQuery.isSuccess.value ? idSet(clientsQuery.data.value?.data) : null,
|
||||
tag_ids: tagsQuery.isSuccess.value ? idSet(tagsQuery.data.value?.data) : null,
|
||||
// Placeholders can not be selected in the member filter either
|
||||
member_ids: membersQuery?.isSuccess.value
|
||||
? idSet(membersQuery.data.value?.data.filter((member) => member.is_placeholder === false))
|
||||
: null,
|
||||
}));
|
||||
|
||||
const form = ref(goalToFormData(props.goal));
|
||||
const errors = ref<GoalFormErrors>({});
|
||||
|
||||
const pendingForm = ref<GoalFormData | null>(null);
|
||||
const unavailableFilters = ref<UnavailableGoalFilter[]>([]);
|
||||
const checkingFilters = computed(() =>
|
||||
(Object.keys(filterQueries) as FilterIdKey[]).some(
|
||||
(key) => form.value[key].some((id) => id !== 'none') && filterQueries[key]?.isFetching.value
|
||||
)
|
||||
);
|
||||
|
||||
watch(show, (isShown) => {
|
||||
pendingForm.value = null;
|
||||
if (isShown) {
|
||||
form.value = goalToFormData(props.goal);
|
||||
errors.value = {};
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || checkingFilters.value) return;
|
||||
errors.value = validateGoalFormData(form.value);
|
||||
if (Object.keys(errors.value).length > 0) {
|
||||
return;
|
||||
}
|
||||
unavailableFilters.value = getUnavailableGoalFilters(form.value, knownFilterIds.value);
|
||||
if (unavailableFilters.value.length > 0) {
|
||||
pendingForm.value = { ...form.value };
|
||||
return;
|
||||
}
|
||||
await save(form.value);
|
||||
}
|
||||
|
||||
async function save(data: GoalFormData, keepExistingFilters = false) {
|
||||
if (saving.value) return;
|
||||
const body = goalFormDataToUpdateBody(data);
|
||||
const originalFilters = goalFormDataToUpdateBody(goalToFormData(props.goal)).filters;
|
||||
// Omitting unchanged filters also lets unrelated edits preserve deleted references.
|
||||
if (keepExistingFilters || JSON.stringify(body.filters) === JSON.stringify(originalFilters)) {
|
||||
delete body.filters;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await updateGoal(props.goal.id, body);
|
||||
if (updated) {
|
||||
show.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
// Other errors were already shown as a notification by the store
|
||||
if (isApiValidationError(error)) {
|
||||
const apiErrors = goalFormErrorsFromApi(getApiValidationFieldErrors(error), form.value);
|
||||
errors.value = apiErrors.errors;
|
||||
// Back from the unavailable filters warning to the form that shows the errors
|
||||
pendingForm.value = null;
|
||||
if (apiErrors.unmapped.length > 0) {
|
||||
addNotification('error', 'Failed to update goal', apiErrors.unmapped[0]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function keepExistingFilters() {
|
||||
if (pendingForm.value) await save(pendingForm.value, true);
|
||||
}
|
||||
|
||||
async function removeUnavailableFilters() {
|
||||
if (pendingForm.value) {
|
||||
await save(withoutUnavailableGoalFilters(pendingForm.value, unavailableFilters.value));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :closeable="!saving" :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span v-if="pendingForm">Unavailable filter items</span>
|
||||
<span v-else> Edit Goal {{ props.goal.name }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="pendingForm" class="space-y-4" data-testid="goal_unavailable_filters">
|
||||
<p>
|
||||
Some selected filter items are unavailable. They may have been deleted or you
|
||||
may no longer have access. Removing them changes which time entries count toward
|
||||
this goal.
|
||||
</p>
|
||||
<ul class="list-disc pl-5 space-y-2">
|
||||
<li v-for="filter in unavailableFilters" :key="filter.key">
|
||||
Unavailable {{ filter.label }}: {{ filter.ids.length }}.
|
||||
<strong v-if="filter.removesRestriction">
|
||||
This will count all {{ filter.label }}, subject to the remaining
|
||||
filters.
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Keeping existing filters saves your other edits and discards any filter edits
|
||||
made in this form.
|
||||
</p>
|
||||
</div>
|
||||
<GoalFormFields
|
||||
v-else
|
||||
v-model="form"
|
||||
:errors="errors"
|
||||
disable-target
|
||||
@submit="submit"></GoalFormFields>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="pendingForm">
|
||||
<SecondaryButton :disabled="saving" @click="pendingForm = null">
|
||||
Back
|
||||
</SecondaryButton>
|
||||
<SecondaryButton class="ms-3" :disabled="saving" @click="removeUnavailableFilters">
|
||||
Remove unavailable items
|
||||
</SecondaryButton>
|
||||
<PrimaryButton class="ms-3" :disabled="saving" @click="keepExistingFilters">
|
||||
Keep existing filters
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SecondaryButton :disabled="saving" @click="show = false"> Cancel </SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving || checkingFilters"
|
||||
@click="submit">
|
||||
Update Goal
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
271
resources/js/Components/Common/Goal/GoalFormFields.vue
Normal file
271
resources/js/Components/Common/Goal/GoalFormFields.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/packages/ui/src/field';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import GoalTimeEntryFilters from '@/Components/Common/Goal/GoalTimeEntryFilters.vue';
|
||||
import TimezoneCombobox from '@/Components/Common/TimezoneCombobox.vue';
|
||||
import WeekStartSelect from '@/Components/Common/WeekStartSelect.vue';
|
||||
import {
|
||||
goalComparisonOptions,
|
||||
goalPeriodOptions,
|
||||
goalTargetOptions,
|
||||
type GoalTarget,
|
||||
} from '@/utils/goals';
|
||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||
import { canCreateOrganizationGoals, canViewMembers } from '@/utils/permissions';
|
||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
import type { GoalFormData, GoalFormErrors } from '@/Components/Common/Goal/goalForm';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
const model = defineModel<GoalFormData>({ required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
errors: GoalFormErrors;
|
||||
// Who a goal is for is decided once, it can not be changed after creation
|
||||
disableTarget?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(nameInput, { initialValue: true });
|
||||
|
||||
const goalsExtensionActivated = isGoalsExtensionActivated();
|
||||
// Goals for another member and goals that count every member are organization goals of the team goals extension
|
||||
const canChooseOrganizationTarget = goalsExtensionActivated && canCreateOrganizationGoals();
|
||||
const { members } = useMembersQuery({
|
||||
enabled: () => goalsExtensionActivated && canViewMembers(),
|
||||
});
|
||||
const currentMembershipId = getCurrentMembershipId() ?? null;
|
||||
const otherMembers = computed(() =>
|
||||
members.value.filter(
|
||||
(member) => member.id !== currentMembershipId && member.is_placeholder === false
|
||||
)
|
||||
);
|
||||
// Without the permission to create organization goals there is nothing to choose
|
||||
const targetOptions = computed(() =>
|
||||
canChooseOrganizationTarget
|
||||
? goalTargetOptions
|
||||
: goalTargetOptions.filter((option) => option.value === 'me')
|
||||
);
|
||||
const targetDescriptions: Record<GoalTarget, string> = {
|
||||
me: 'Only you can see this goal.',
|
||||
other: 'The member and everyone who manages goals can see it.',
|
||||
organization: 'Only people who manage goals can see it.',
|
||||
};
|
||||
// Only a goal that counts every member can be narrowed down to a subset of the members, placeholders can not be selected
|
||||
const memberFilterOptions = computed(() =>
|
||||
model.value.target === 'organization'
|
||||
? members.value
|
||||
.filter((member) => member.is_placeholder === false)
|
||||
.map((member) => ({ id: member.id, name: member.name }))
|
||||
: null
|
||||
);
|
||||
// A goal for one member has no member filter, and a goal that counts every member has no single member
|
||||
watch(
|
||||
() => model.value.target,
|
||||
(target) => {
|
||||
if (target !== 'other') {
|
||||
model.value.other_member_id = null;
|
||||
}
|
||||
if (target !== 'organization') {
|
||||
model.value.member_ids = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const errors = computed(() => props.errors);
|
||||
|
||||
// The settings are collapsed by default, open them when one of their fields has an error
|
||||
const openSettings = ref<string | undefined>(undefined);
|
||||
watch(errors, (newErrors) => {
|
||||
if (newErrors.timezone || newErrors.week_start) {
|
||||
openSettings.value = 'period-settings';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel for="goalName">Goal name</FieldLabel>
|
||||
<TextInput
|
||||
id="goalName"
|
||||
ref="nameInput"
|
||||
v-model="model.name"
|
||||
type="text"
|
||||
placeholder="e.g. Deep work on Project X"
|
||||
class="block w-full"
|
||||
required
|
||||
@keydown.enter="emit('submit')" />
|
||||
<FieldError v-if="errors.name">{{ errors.name }}</FieldError>
|
||||
</Field>
|
||||
<Field v-if="canChooseOrganizationTarget">
|
||||
<FieldLabel for="goalFor">This goal is for</FieldLabel>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<Select v-model="model.target" :disabled="props.disableTarget">
|
||||
<SelectTrigger
|
||||
id="goalFor"
|
||||
class="w-full sm:w-1/2"
|
||||
aria-label="This goal is for">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in targetOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</SelectItem
|
||||
>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="model.target === 'other'" class="flex flex-col gap-1.5 sm:w-1/2">
|
||||
<Select v-model="model.other_member_id" :disabled="props.disableTarget">
|
||||
<SelectTrigger id="goalMember" class="w-full" aria-label="Member">
|
||||
<SelectValue placeholder="Choose a member" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="member in otherMembers"
|
||||
:key="member.id"
|
||||
:value="member.id"
|
||||
>{{ member.name }}</SelectItem
|
||||
>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError v-if="errors.other_member_id">{{
|
||||
errors.other_member_id
|
||||
}}</FieldError>
|
||||
</div>
|
||||
</div>
|
||||
<FieldDescription>{{ targetDescriptions[model.target] }}</FieldDescription>
|
||||
</Field>
|
||||
<FieldGroup class="gap-4 sm:flex-row sm:items-start">
|
||||
<Field class="sm:w-1/3">
|
||||
<FieldLabel for="goalComparison">Target type</FieldLabel>
|
||||
<Select v-model="model.comparison">
|
||||
<SelectTrigger id="goalComparison" class="w-full" aria-label="Target type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in goalComparisonOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</SelectItem
|
||||
>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError v-if="errors.comparison">{{ errors.comparison }}</FieldError>
|
||||
</Field>
|
||||
<Field class="sm:w-1/3">
|
||||
<div class="flex items-center gap-1">
|
||||
<FieldLabel for="goalTarget">Target time</FieldLabel>
|
||||
<TooltipProvider :delay-duration="100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="text-icon-default hover:text-text-primary cursor-default rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="How to enter the target time">
|
||||
<QuestionMarkCircleIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-64">
|
||||
You can type natural language for the target time like
|
||||
<span class="font-semibold">2h 30m</span> or
|
||||
<span class="font-semibold">1.5</span> hours.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<EstimatedTimeInput
|
||||
id="goalTarget"
|
||||
v-model="model.target_seconds"
|
||||
@submit="emit('submit')"></EstimatedTimeInput>
|
||||
<FieldError v-if="errors.target_seconds">{{ errors.target_seconds }}</FieldError>
|
||||
</Field>
|
||||
<Field class="sm:w-1/3">
|
||||
<FieldLabel for="goalPeriod">Period</FieldLabel>
|
||||
<Select v-model="model.period">
|
||||
<SelectTrigger id="goalPeriod" class="w-full" aria-label="Period">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in goalPeriodOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</SelectItem
|
||||
>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError v-if="errors.period">{{ errors.period }}</FieldError>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Only count time entries with</FieldLabel>
|
||||
<GoalTimeEntryFilters v-model="model" :member-options="memberFilterOptions" />
|
||||
<FieldError v-if="errors.filters">{{ errors.filters }}</FieldError>
|
||||
<FieldDescription>
|
||||
Values within a filter use "or", filters use "and". Without filters every entry
|
||||
counts.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Accordion v-model="openSettings" type="single" collapsible>
|
||||
<AccordionItem value="period-settings">
|
||||
<AccordionTrigger data-testid="goal_period_settings">
|
||||
Additional Settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<FieldGroup class="sm:flex-row sm:items-start pt-2">
|
||||
<Field class="sm:w-1/2">
|
||||
<FieldLabel for="goalTimezone">Timezone</FieldLabel>
|
||||
<TimezoneCombobox id="goalTimezone" v-model="model.timezone" />
|
||||
<FieldError v-if="errors.timezone">{{ errors.timezone }}</FieldError>
|
||||
</Field>
|
||||
<Field class="sm:w-1/2">
|
||||
<FieldLabel for="goalWeekStart">Start of the week</FieldLabel>
|
||||
<WeekStartSelect id="goalWeekStart" v-model="model.week_start" />
|
||||
<FieldError v-if="errors.week_start">{{
|
||||
errors.week_start
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldDescription class="pt-2">
|
||||
Timezone and start of the week decide when a period of this goal begins and
|
||||
ends. They default to your own settings.
|
||||
</FieldDescription>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { ArchiveBoxIcon, TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
archive: [];
|
||||
delete: [];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
goal: Goal;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
|
||||
:aria-label="'Actions for Goal ' + props.goal.name">
|
||||
<svg
|
||||
class="h-8 w-8 p-1 rounded-full"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="props.canEdit"
|
||||
:aria-label="'Edit Goal ' + props.goal.name"
|
||||
data-testid="goal_edit"
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('edit')">
|
||||
<PencilSquareIcon class="w-5 text-icon-active" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.canEdit"
|
||||
:aria-label="
|
||||
(props.goal.is_archived ? 'Unarchive' : 'Archive') + ' Goal ' + props.goal.name
|
||||
"
|
||||
data-testid="goal_archive"
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('archive')">
|
||||
<ArchiveBoxIcon class="w-5 text-icon-active" />
|
||||
<span>{{ props.goal.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.canDelete"
|
||||
:aria-label="'Delete Goal ' + props.goal.name"
|
||||
data-testid="goal_delete"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
@click="emit('delete')">
|
||||
<TrashIcon class="w-5" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
59
resources/js/Components/Common/Goal/GoalProgressBar.vue
Normal file
59
resources/js/Components/Common/Goal/GoalProgressBar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import type { Goal, Organization } from '@/packages/api/src';
|
||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
|
||||
const props = defineProps<{
|
||||
goal: Goal;
|
||||
hideTarget?: boolean;
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
// Progress is computed on the server, it is refreshed by the goals query while the page is open
|
||||
const percentage = computed(() => {
|
||||
if (props.goal.target_seconds <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, (props.goal.progress.tracked_seconds / props.goal.target_seconds) * 100);
|
||||
});
|
||||
|
||||
const barClass = computed(() => {
|
||||
if (props.goal.progress.status === 'exceeded') {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
if (props.goal.progress.status === 'achieved') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
return 'bg-accent-200';
|
||||
});
|
||||
|
||||
function format(seconds: number) {
|
||||
return formatHumanReadableDuration(
|
||||
seconds,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-w-0" data-testid="goal_progress">
|
||||
<div class="bg-tertiary h-1 rounded relative overflow-hidden w-full">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="barClass"
|
||||
:style="{ width: percentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-xs pt-1.5 flex items-center justify-between space-x-2">
|
||||
<span class="text-text-primary tabular-nums" data-testid="goal_tracked_time">{{
|
||||
format(goal.progress.tracked_seconds)
|
||||
}}</span>
|
||||
<span v-if="!hideTarget" class="text-text-secondary">
|
||||
of {{ format(goal.target_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
150
resources/js/Components/Common/Goal/GoalScopeBadges.vue
Normal file
150
resources/js/Components/Common/Goal/GoalScopeBadges.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { canViewMembers } from '@/utils/permissions';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/packages/ui/src';
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { CheckCircleIcon, TagIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
|
||||
const props = defineProps<{
|
||||
goal: Goal;
|
||||
}>();
|
||||
|
||||
const { projects } = useProjectsQuery();
|
||||
const { tasks } = useTasksQuery();
|
||||
const { clients } = useClientsQuery();
|
||||
const { tags } = useTagsQuery();
|
||||
// Only goals that count every member can be narrowed down to a subset of the members
|
||||
const { members } = useMembersQuery({ enabled: canViewMembers });
|
||||
|
||||
function names(
|
||||
ids: string[] | null,
|
||||
items: { id: string; name: string }[],
|
||||
noneLabel: string
|
||||
): { id: string; name: string }[] {
|
||||
if (ids === null) {
|
||||
return [];
|
||||
}
|
||||
return ids.map((id) => {
|
||||
if (id === 'none') {
|
||||
return { id, name: noneLabel };
|
||||
}
|
||||
return { id, name: items.find((item) => item.id === id)?.name ?? 'Unknown' };
|
||||
});
|
||||
}
|
||||
|
||||
const badges = computed(() => {
|
||||
const filters = props.goal.filters;
|
||||
const notPrefix = filters.tag_match_type === 'not_contains' ? 'Not ' : '';
|
||||
// Fixed display order: members, projects, tasks, clients, tags
|
||||
const sources = [
|
||||
{
|
||||
kind: 'member',
|
||||
ids: filters.member_ids,
|
||||
items: members.value,
|
||||
none: 'Unknown member',
|
||||
icon: UserGroupIcon,
|
||||
prefix: '',
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
ids: filters.project_ids,
|
||||
items: projects.value,
|
||||
none: 'No project',
|
||||
icon: FolderIcon,
|
||||
prefix: '',
|
||||
},
|
||||
{
|
||||
kind: 'task',
|
||||
ids: filters.task_ids,
|
||||
items: tasks.value,
|
||||
none: 'No task',
|
||||
icon: CheckCircleIcon,
|
||||
prefix: '',
|
||||
},
|
||||
{
|
||||
kind: 'client',
|
||||
ids: filters.client_ids,
|
||||
items: clients.value,
|
||||
none: 'No client',
|
||||
icon: UserCircleIcon,
|
||||
prefix: '',
|
||||
},
|
||||
{
|
||||
kind: 'tag',
|
||||
ids: filters.tag_ids,
|
||||
items: tags.value,
|
||||
none: 'No tag',
|
||||
icon: TagIcon,
|
||||
prefix: notPrefix,
|
||||
},
|
||||
];
|
||||
const result: { key: string; icon: unknown; label: string }[] = sources.flatMap((source) =>
|
||||
names(source.ids, source.items, source.none).map((entry) => ({
|
||||
key: source.kind + '-' + entry.id,
|
||||
icon: source.icon,
|
||||
label: source.prefix + entry.name,
|
||||
}))
|
||||
);
|
||||
if (filters.billable !== null) {
|
||||
result.push({
|
||||
key: 'billable',
|
||||
icon: BillableIcon,
|
||||
label: filters.billable ? 'Billable' : 'Non billable',
|
||||
});
|
||||
}
|
||||
if (filters.time_entry_type === 'break') {
|
||||
result.push({ key: 'type', icon: Coffee, label: 'Breaks' });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// The list keeps every row on a single line, the first badge is shown and all of them are listed on hover
|
||||
const firstBadge = computed(() => badges.value[0] ?? null);
|
||||
const hiddenBadges = computed(() => badges.value.slice(1));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center min-w-0 text-sm" data-testid="goal_scope">
|
||||
<span v-if="firstBadge === null" class="text-text-secondary whitespace-nowrap"
|
||||
>All time entries</span
|
||||
>
|
||||
<TooltipProvider v-else :delay-duration="100">
|
||||
<Tooltip :disabled="hiddenBadges.length === 0">
|
||||
<TooltipTrigger as-child>
|
||||
<span class="flex items-center gap-1.5 max-w-full min-w-0 cursor-default">
|
||||
<component
|
||||
:is="firstBadge.icon"
|
||||
class="w-3.5 h-3.5 text-icon-default shrink-0"></component>
|
||||
<span class="truncate">{{ firstBadge.label }}</span>
|
||||
<span v-if="hiddenBadges.length > 0" class="text-text-secondary shrink-0"
|
||||
>+{{ hiddenBadges.length }}</span
|
||||
>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="min-w-48 px-3 py-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.key"
|
||||
class="flex items-center gap-1.5">
|
||||
<component
|
||||
:is="badge.icon"
|
||||
class="w-3.5 h-3.5 text-icon-default shrink-0"></component>
|
||||
<span>{{ badge.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
41
resources/js/Components/Common/Goal/GoalStatusBadge.vue
Normal file
41
resources/js/Components/Common/Goal/GoalStatusBadge.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { CheckCircleIcon, ExclamationCircleIcon, ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import type { GoalStatus } from '@/packages/api/src';
|
||||
import { goalStatusIsPositive, goalStatusLabel } from '@/utils/goals';
|
||||
|
||||
const props = defineProps<{
|
||||
status: GoalStatus;
|
||||
}>();
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.status === 'exceeded') {
|
||||
return ExclamationCircleIcon;
|
||||
}
|
||||
if (props.status === 'achieved') {
|
||||
return CheckCircleIcon;
|
||||
}
|
||||
return ClockIcon;
|
||||
});
|
||||
|
||||
const colorClass = computed(() => {
|
||||
if (props.status === 'exceeded') {
|
||||
return 'text-red-500';
|
||||
}
|
||||
if (goalStatusIsPositive(props.status)) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
return 'text-icon-default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center space-x-1.5 text-sm text-text-primary"
|
||||
:data-status="status">
|
||||
<component :is="icon" class="w-4" :class="colorClass"></component>
|
||||
<span>{{ goalStatusLabel(status) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
69
resources/js/Components/Common/Goal/GoalTable.vue
Normal file
69
resources/js/Components/Common/Goal/GoalTable.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { ref } from 'vue';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
||||
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { LoadingSpinner } from '@/packages/ui/src';
|
||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||
|
||||
const props = defineProps<{
|
||||
goals: Goal[];
|
||||
isLoading: boolean;
|
||||
canCreateMoreGoals: boolean;
|
||||
showingArchived: boolean;
|
||||
}>();
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showTargetColumn = isGoalsExtensionActivated();
|
||||
// Every column gets a share of the free space so the columns are spread evenly over the table
|
||||
const gridTemplateColumns = [
|
||||
'minmax(180px, 1.25fr)',
|
||||
...(showTargetColumn ? ['minmax(120px, 0.75fr)'] : []),
|
||||
'minmax(200px, 1fr)',
|
||||
'minmax(180px, 1fr)',
|
||||
'minmax(160px, 1fr)',
|
||||
'minmax(120px, 0.75fr)',
|
||||
'80px',
|
||||
].join(' ');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
||||
<GoalTableHeading :show-target-column="showTargetColumn"></GoalTableHeading>
|
||||
<div v-if="props.isLoading" class="col-span-full py-24 text-center">
|
||||
<LoadingSpinner></LoadingSpinner>
|
||||
</div>
|
||||
<div v-else-if="props.goals.length === 0" class="col-span-full py-24 text-center">
|
||||
<Target class="w-8 h-8 text-icon-default inline pb-2"></Target>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{ props.showingArchived ? 'No archived goals found' : 'No goals found' }}
|
||||
</h3>
|
||||
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
||||
Set yourself a target and track your progress every day, week or month.
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="
|
||||
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
|
||||
"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
>Create your first goal
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template v-for="goal in props.goals" :key="goal.id">
|
||||
<GoalTableRow
|
||||
:goal="goal"
|
||||
:show-target-column="showTargetColumn"></GoalTableRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
resources/js/Components/Common/Goal/GoalTableHeading.vue
Normal file
26
resources/js/Components/Common/Goal/GoalTableHeading.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
defineProps<{
|
||||
showTargetColumn: boolean;
|
||||
}>();
|
||||
</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">
|
||||
Goal
|
||||
</div>
|
||||
<div v-if="showTargetColumn" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
For
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Target</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Scope</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="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
</TableHeading>
|
||||
</template>
|
||||
145
resources/js/Components/Common/Goal/GoalTableRow.vue
Normal file
145
resources/js/Components/Common/Goal/GoalTableRow.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import type { Goal, Organization } from '@/packages/api/src';
|
||||
import { computed, inject, ref, type ComputedRef } from 'vue';
|
||||
import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import GoalMoreOptionsDropdown from '@/Components/Common/Goal/GoalMoreOptionsDropdown.vue';
|
||||
import GoalEditModal from '@/Components/Common/Goal/GoalEditModal.vue';
|
||||
import GoalProgressBar from '@/Components/Common/Goal/GoalProgressBar.vue';
|
||||
import GoalScopeBadges from '@/Components/Common/Goal/GoalScopeBadges.vue';
|
||||
import GoalStatusBadge from '@/Components/Common/Goal/GoalStatusBadge.vue';
|
||||
import { useGoalsStore } from '@/utils/useGoals';
|
||||
import { canDeleteGoals, canUpdateGoals } from '@/utils/permissions';
|
||||
import {
|
||||
canManageGoal,
|
||||
goalComparisonLabel,
|
||||
goalPeriodOptions,
|
||||
goalTypeLabel,
|
||||
} from '@/utils/goals';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
|
||||
const props = defineProps<{
|
||||
goal: Goal;
|
||||
showTargetColumn: boolean;
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const showEditModal = ref(false);
|
||||
// A personal goal is managed by its member, an organization goal by the members that may manage goals
|
||||
const isManager = computed(() => canManageGoal(props.goal));
|
||||
const canEdit = computed(() => isManager.value && canUpdateGoals());
|
||||
const canDelete = computed(() => isManager.value && canDeleteGoals());
|
||||
const targetLabel = computed(() => {
|
||||
if (props.goal.member_id === null) return 'Every member';
|
||||
if (props.goal.member_id === getCurrentMembershipId()) return 'You';
|
||||
return props.goal.member_name ?? 'Member';
|
||||
});
|
||||
const typeDescription = computed(() => goalTypeLabel(props.goal));
|
||||
|
||||
const targetDescription = computed(() => {
|
||||
const period = goalPeriodOptions.find((option) => option.value === props.goal.period)?.label;
|
||||
return (
|
||||
goalComparisonLabel(props.goal.comparison) +
|
||||
' ' +
|
||||
formatHumanReadableDuration(
|
||||
props.goal.target_seconds,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
) +
|
||||
' ' +
|
||||
period
|
||||
);
|
||||
});
|
||||
|
||||
function deleteGoal() {
|
||||
useGoalsStore().deleteGoal(props.goal.id);
|
||||
}
|
||||
|
||||
function toggleArchived() {
|
||||
useGoalsStore().setGoalArchived(props.goal.id, !props.goal.is_archived);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<TableRow :data-testid="'goal_row_' + goal.id">
|
||||
<div
|
||||
class="whitespace-nowrap min-w-0 flex items-center gap-2 py-4 pr-3 text-sm pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span
|
||||
class="font-medium text-text-primary overflow-ellipsis overflow-hidden"
|
||||
data-testid="goal_name"
|
||||
>{{ goal.name }}</span
|
||||
>
|
||||
<Badge v-if="goal.is_archived" size="base" data-testid="goal_archived_badge"
|
||||
>Archived</Badge
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="showTargetColumn"
|
||||
class="min-w-0 flex items-center px-3 py-4 text-sm text-text-primary"
|
||||
data-testid="goal_target">
|
||||
<span class="sr-only">{{ typeDescription }}</span>
|
||||
<span class="truncate">{{ targetLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary"
|
||||
data-testid="goal_target_description">
|
||||
{{ targetDescription }}
|
||||
</div>
|
||||
<!-- Like the progress in the project table, the cells without vertical padding keep the row as high as the other tables -->
|
||||
<div class="min-w-0 flex items-center px-3 text-sm text-text-primary">
|
||||
<GoalScopeBadges :goal="goal"></GoalScopeBadges>
|
||||
</div>
|
||||
<div class="whitespace-nowrap flex items-center px-3 text-sm text-text-primary">
|
||||
<GoalProgressBar :goal="goal" class="max-w-44"></GoalProgressBar>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
|
||||
<GoalStatusBadge :status="goal.progress.status"></GoalStatusBadge>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<GoalMoreOptionsDropdown
|
||||
v-if="canDelete || canEdit"
|
||||
:goal="goal"
|
||||
:can-edit="canEdit"
|
||||
:can-delete="canDelete"
|
||||
@edit="showEditModal = true"
|
||||
@archive="toggleArchived"
|
||||
@delete="deleteGoal"></GoalMoreOptionsDropdown>
|
||||
</div>
|
||||
<GoalEditModal v-model:show="showEditModal" :goal="goal"></GoalEditModal>
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="min-w-[160px]">
|
||||
<ContextMenuItem v-if="canEdit" class="space-x-3" @select="showEditModal = true">
|
||||
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Edit</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canEdit" class="space-x-3" @select="toggleArchived()">
|
||||
<ArchiveBoxIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>{{ goal.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="canDelete" />
|
||||
<ContextMenuItem
|
||||
v-if="canDelete"
|
||||
class="space-x-3 text-destructive"
|
||||
@select="deleteGoal()">
|
||||
<TrashIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Delete</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
243
resources/js/Components/Common/Goal/GoalTimeEntryFilters.vue
Normal file
243
resources/js/Components/Common/Goal/GoalTimeEntryFilters.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { FolderIcon, UserCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import { Check, Coffee } from '@lucide/vue';
|
||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||
import type { GoalFormData } from '@/Components/Common/Goal/goalForm';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
|
||||
type FilterOption = { id: string; name: string };
|
||||
|
||||
const model = defineModel<GoalFormData>({ required: true });
|
||||
|
||||
defineProps<{
|
||||
// Members that can be selected in the member filter, the filter is hidden without them
|
||||
memberOptions?: FilterOption[] | null;
|
||||
}>();
|
||||
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
const { projects } = useProjectsQuery();
|
||||
const { tasks } = useTasksQuery();
|
||||
const { clients } = useClientsQuery();
|
||||
const { tags } = useTagsQuery();
|
||||
|
||||
// Client and project filters are ANDed, so only offer projects of the selected clients. Projects
|
||||
// that are already selected stay listed so they can still be unselected.
|
||||
const visibleProjects = computed(() => {
|
||||
const clientIds = model.value.client_ids;
|
||||
if (clientIds.length === 0) return projects.value;
|
||||
return projects.value.filter(
|
||||
(project) =>
|
||||
(project.client_id !== null && clientIds.includes(project.client_id)) ||
|
||||
model.value.project_ids.includes(project.id)
|
||||
);
|
||||
});
|
||||
// Project and task filters are ANDed, so only offer tasks from the selected projects. Tasks
|
||||
// that are already selected stay listed so they can still be unselected.
|
||||
const visibleTasks = computed(() => {
|
||||
const projectIds = model.value.project_ids;
|
||||
if (projectIds.length === 0) return tasks.value;
|
||||
return tasks.value.filter(
|
||||
(task) => projectIds.includes(task.project_id) || model.value.task_ids.includes(task.id)
|
||||
);
|
||||
});
|
||||
|
||||
function getKeyFromItem(item: FilterOption) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getNameForItem(item: FilterOption) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
|
||||
{ value: 'contains', label: 'Contains' },
|
||||
{ value: 'not_contains', label: 'Does Not Contain' },
|
||||
];
|
||||
|
||||
function selectTagMatchType(value: AcceptableValue) {
|
||||
model.value.tag_match_type = value as TagMatchType;
|
||||
}
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await useTagsStore().createTag(name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2" data-testid="goal_filters">
|
||||
<MultiselectDropdown
|
||||
v-if="memberOptions"
|
||||
v-model="model.member_ids"
|
||||
search-placeholder="Search for a Member..."
|
||||
:items="memberOptions"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
data-testid="goal_filter_member_ids"
|
||||
:count="model.member_ids.length"
|
||||
:active="model.member_ids.length > 0"
|
||||
title="Members"
|
||||
:icon="UserGroupIcon" />
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
<MultiselectDropdown
|
||||
v-model="model.project_ids"
|
||||
search-placeholder="Search for a Project..."
|
||||
:items="visibleProjects"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Project">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
data-testid="goal_filter_project_ids"
|
||||
:count="model.project_ids.length"
|
||||
:active="model.project_ids.length > 0"
|
||||
title="Projects"
|
||||
:icon="FolderIcon" />
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
<MultiselectDropdown
|
||||
v-model="model.task_ids"
|
||||
search-placeholder="Search for a Task..."
|
||||
:items="visibleTasks"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Task">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
data-testid="goal_filter_task_ids"
|
||||
:count="model.task_ids.length"
|
||||
:active="model.task_ids.length > 0"
|
||||
title="Tasks"
|
||||
:icon="CheckCircleIcon" />
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
<MultiselectDropdown
|
||||
v-model="model.client_ids"
|
||||
search-placeholder="Search for a Client..."
|
||||
:items="clients"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Client">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
data-testid="goal_filter_client_ids"
|
||||
:count="model.client_ids.length"
|
||||
:active="model.client_ids.length > 0"
|
||||
title="Clients"
|
||||
:icon="UserCircleIcon" />
|
||||
</template>
|
||||
</MultiselectDropdown>
|
||||
<TagDropdown v-model="model.tag_ids" :create-tag :tags="tags">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
data-testid="goal_filter_tag_ids"
|
||||
:count="model.tag_ids.length"
|
||||
:active="model.tag_ids.length > 0"
|
||||
title="Tags"
|
||||
:icon="TagIcon" />
|
||||
</template>
|
||||
<template #content-before-list>
|
||||
<div class="mt-2 border-b border-card-background-separator pb-2">
|
||||
<div
|
||||
id="goal-tag-match-type-label"
|
||||
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
|
||||
Match
|
||||
</div>
|
||||
<RadioGroupRoot
|
||||
:model-value="model.tag_match_type"
|
||||
aria-labelledby="goal-tag-match-type-label"
|
||||
class="space-y-1"
|
||||
@update:model-value="selectTagMatchType">
|
||||
<RadioGroupItem
|
||||
v-for="option in tagMatchOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
|
||||
{{ option.label }}
|
||||
<span
|
||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadioGroupIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</RadioGroupIndicator>
|
||||
</span>
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
</div>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Select v-model="model.billable">
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:active="model.billable !== null"
|
||||
:show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon
|
||||
class="h-4"
|
||||
:class="
|
||||
model.billable !== null
|
||||
? 'dark:text-accent-300/80 text-accent-400/80'
|
||||
: 'text-text-quaternary'
|
||||
" />
|
||||
<span class="text-text-secondary">{{
|
||||
model.billable === 'false' ? 'Non Billable' : 'Billable'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Both</SelectItem>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-if="breaksEnabled" v-model="model.time_entry_type">
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:active="model.time_entry_type !== null"
|
||||
:show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<Coffee
|
||||
class="h-4 w-4"
|
||||
:class="
|
||||
model.time_entry_type !== null
|
||||
? 'dark:text-accent-300/80 text-accent-400/80'
|
||||
: 'text-text-quaternary'
|
||||
" />
|
||||
<span class="text-text-secondary">{{
|
||||
model.time_entry_type === null
|
||||
? 'Type'
|
||||
: model.time_entry_type === 'break'
|
||||
? 'Breaks'
|
||||
: 'Work time'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Both</SelectItem>
|
||||
<SelectItem value="work">Work time</SelectItem>
|
||||
<SelectItem value="break">Breaks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p v-if="memberOptions && model.member_ids.length > 0" class="text-xs text-text-tertiary">
|
||||
Selected members do not get access to the goal.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
254
resources/js/Components/Common/Goal/goalForm.ts
Normal file
254
resources/js/Components/Common/Goal/goalForm.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type {
|
||||
CreateGoalBody,
|
||||
Goal,
|
||||
GoalComparison,
|
||||
GoalPeriod,
|
||||
GoalType,
|
||||
GoalWeekStart,
|
||||
UpdateGoalBody,
|
||||
} from '@/packages/api/src';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
import type { GoalTarget } from '@/utils/goals';
|
||||
import { getCurrentMembershipId, getCurrentUser } from '@/utils/useUser';
|
||||
|
||||
export interface GoalFormData {
|
||||
name: string;
|
||||
// Who the goal is for, this decides the type and the member of the goal
|
||||
target: GoalTarget;
|
||||
comparison: GoalComparison;
|
||||
target_seconds: number | null;
|
||||
period: GoalPeriod;
|
||||
// ID of the member the goal is for, only used for the target "other"
|
||||
other_member_id: string | null;
|
||||
timezone: string;
|
||||
week_start: GoalWeekStart;
|
||||
// Members whose time counts, only used for organization goals, empty = every member
|
||||
member_ids: string[];
|
||||
project_ids: string[];
|
||||
task_ids: string[];
|
||||
tag_ids: string[];
|
||||
tag_match_type: TagMatchType;
|
||||
client_ids: string[];
|
||||
billable: 'true' | 'false' | null;
|
||||
time_entry_type: 'work' | 'break' | null;
|
||||
}
|
||||
|
||||
export function emptyGoalFormData(): GoalFormData {
|
||||
return {
|
||||
name: '',
|
||||
target: 'me',
|
||||
comparison: 'at_least',
|
||||
target_seconds: null,
|
||||
period: 'week',
|
||||
other_member_id: null,
|
||||
timezone: getCurrentUser().timezone,
|
||||
week_start: getCurrentUser().week_start as GoalWeekStart,
|
||||
member_ids: [],
|
||||
project_ids: [],
|
||||
task_ids: [],
|
||||
tag_ids: [],
|
||||
tag_match_type: 'contains',
|
||||
client_ids: [],
|
||||
billable: null,
|
||||
// Breaks are excluded by default, the user can widen the scope explicitly
|
||||
time_entry_type: 'work',
|
||||
};
|
||||
}
|
||||
|
||||
export function goalTarget(goal: Goal): GoalTarget {
|
||||
if (goal.type === 'personal') {
|
||||
return 'me';
|
||||
}
|
||||
return goal.member_id === null ? 'organization' : 'other';
|
||||
}
|
||||
|
||||
export function goalToFormData(goal: Goal): GoalFormData {
|
||||
const target = goalTarget(goal);
|
||||
return {
|
||||
name: goal.name,
|
||||
target,
|
||||
comparison: goal.comparison,
|
||||
target_seconds: goal.target_seconds,
|
||||
period: goal.period,
|
||||
other_member_id: target === 'other' ? goal.member_id : null,
|
||||
timezone: goal.timezone,
|
||||
week_start: goal.week_start,
|
||||
member_ids: goal.filters.member_ids ?? [],
|
||||
project_ids: goal.filters.project_ids ?? [],
|
||||
task_ids: goal.filters.task_ids ?? [],
|
||||
tag_ids: goal.filters.tag_ids ?? [],
|
||||
tag_match_type: goal.filters.tag_match_type ?? 'contains',
|
||||
client_ids: goal.filters.client_ids ?? [],
|
||||
billable: goal.filters.billable === null ? null : goal.filters.billable ? 'true' : 'false',
|
||||
time_entry_type: goal.filters.time_entry_type,
|
||||
};
|
||||
}
|
||||
|
||||
export type FilterIdKey = 'member_ids' | 'project_ids' | 'task_ids' | 'tag_ids' | 'client_ids';
|
||||
|
||||
// Selectable IDs, or null when the list is unavailable. Missing IDs may still exist.
|
||||
export type KnownFilterIds = Record<FilterIdKey, Set<string> | null>;
|
||||
|
||||
export interface UnavailableGoalFilter {
|
||||
key: FilterIdKey;
|
||||
label: string;
|
||||
ids: string[];
|
||||
removesRestriction: boolean;
|
||||
}
|
||||
|
||||
const filterLabels: Record<FilterIdKey, string> = {
|
||||
member_ids: 'members',
|
||||
project_ids: 'projects',
|
||||
task_ids: 'tasks',
|
||||
tag_ids: 'tags',
|
||||
client_ids: 'clients',
|
||||
};
|
||||
|
||||
export function getUnavailableGoalFilters(
|
||||
data: GoalFormData,
|
||||
known: KnownFilterIds
|
||||
): UnavailableGoalFilter[] {
|
||||
return (Object.keys(known) as FilterIdKey[]).flatMap((key) => {
|
||||
const knownIds = known[key];
|
||||
if (knownIds === null) return [];
|
||||
const ids = data[key].filter((id) => id !== 'none' && !knownIds.has(id));
|
||||
return ids.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key,
|
||||
label: filterLabels[key],
|
||||
ids,
|
||||
removesRestriction: ids.length === data[key].length,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// Remove only the IDs the user has reviewed and explicitly chosen to remove.
|
||||
export function withoutUnavailableGoalFilters(
|
||||
data: GoalFormData,
|
||||
unavailable: UnavailableGoalFilter[]
|
||||
): GoalFormData {
|
||||
const result = { ...data };
|
||||
for (const { key, ids } of unavailable) {
|
||||
result[key] = data[key].filter((id) => !ids.includes(id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function idsOrNull(ids: string[]): string[] | null {
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function goalType(target: GoalTarget): GoalType {
|
||||
return target === 'me' ? 'personal' : 'organization';
|
||||
}
|
||||
|
||||
function goalMemberId(data: GoalFormData): string | null {
|
||||
if (data.target === 'me') {
|
||||
return getCurrentMembershipId() ?? null;
|
||||
}
|
||||
return data.target === 'other' ? data.other_member_id : null;
|
||||
}
|
||||
|
||||
function goalFilters(data: GoalFormData) {
|
||||
return {
|
||||
// Only a goal that counts every member can be narrowed down to a subset of the members
|
||||
member_ids: data.target === 'organization' ? idsOrNull(data.member_ids) : null,
|
||||
project_ids: idsOrNull(data.project_ids),
|
||||
task_ids: idsOrNull(data.task_ids),
|
||||
tag_ids: idsOrNull(data.tag_ids),
|
||||
tag_match_type: data.tag_ids.length > 0 ? data.tag_match_type : null,
|
||||
client_ids: idsOrNull(data.client_ids),
|
||||
billable: data.billable === null ? null : data.billable === 'true',
|
||||
time_entry_type: data.time_entry_type,
|
||||
};
|
||||
}
|
||||
|
||||
export function goalFormDataToCreateBody(data: GoalFormData): CreateGoalBody {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
type: goalType(data.target),
|
||||
comparison: data.comparison,
|
||||
target_seconds: data.target_seconds ?? 0,
|
||||
period: data.period,
|
||||
member_id: goalMemberId(data),
|
||||
timezone: data.timezone,
|
||||
week_start: data.week_start,
|
||||
filters: goalFilters(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of a goal and the member it is for can not be changed after creation.
|
||||
*/
|
||||
export function goalFormDataToUpdateBody(data: GoalFormData): UpdateGoalBody {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
comparison: data.comparison,
|
||||
target_seconds: data.target_seconds ?? 0,
|
||||
period: data.period,
|
||||
timezone: data.timezone,
|
||||
week_start: data.week_start,
|
||||
filters: goalFilters(data),
|
||||
};
|
||||
}
|
||||
|
||||
export interface GoalFormErrors {
|
||||
name?: string;
|
||||
target_seconds?: string;
|
||||
other_member_id?: string;
|
||||
comparison?: string;
|
||||
period?: string;
|
||||
filters?: string;
|
||||
timezone?: string;
|
||||
week_start?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the field errors of a 422 response (see getApiValidationFieldErrors) to the fields of the goal form.
|
||||
* Errors that no visible field can show (e.g. member_id of a goal for yourself) are returned as unmapped.
|
||||
*/
|
||||
export function goalFormErrorsFromApi(
|
||||
fieldErrors: Record<string, string>,
|
||||
data: GoalFormData
|
||||
): { errors: GoalFormErrors; unmapped: string[] } {
|
||||
const errors: GoalFormErrors = {};
|
||||
const unmapped: string[] = [];
|
||||
const direct = [
|
||||
'name',
|
||||
'target_seconds',
|
||||
'comparison',
|
||||
'period',
|
||||
'timezone',
|
||||
'week_start',
|
||||
] as const;
|
||||
for (const [field, message] of Object.entries(fieldErrors)) {
|
||||
const directField = direct.find((key) => key === field);
|
||||
if (directField !== undefined) {
|
||||
errors[directField] ??= message;
|
||||
} else if (field === 'member_id' && data.target === 'other') {
|
||||
errors.other_member_id ??= message;
|
||||
} else if (field === 'filters' || field.startsWith('filters.')) {
|
||||
errors.filters ??= message;
|
||||
} else {
|
||||
unmapped.push(message);
|
||||
}
|
||||
}
|
||||
return { errors, unmapped };
|
||||
}
|
||||
|
||||
export function validateGoalFormData(data: GoalFormData): GoalFormErrors {
|
||||
const errors: GoalFormErrors = {};
|
||||
if (data.name.trim() === '') {
|
||||
errors.name = 'Please enter a name for the goal.';
|
||||
}
|
||||
if (data.target_seconds === null || data.target_seconds <= 0) {
|
||||
errors.target_seconds = 'Please enter a target time greater than zero.';
|
||||
}
|
||||
if (data.target === 'other' && data.other_member_id === null) {
|
||||
errors.other_member_id = 'Please choose the member this goal is for.';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -3,11 +3,15 @@ import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||
import type { Component } from 'vue';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
label: string;
|
||||
filterName: string;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
label: string;
|
||||
filterName: string;
|
||||
operator?: string;
|
||||
}>(),
|
||||
{ operator: 'is' }
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
remove: [];
|
||||
@@ -26,7 +30,7 @@ defineSlots<{
|
||||
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 whitespace-nowrap">
|
||||
<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-muted-foreground">{{ operator }}</span>
|
||||
<span class="text-foreground">{{ label }}</span>
|
||||
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -36,6 +40,8 @@ defineSlots<{
|
||||
</DropdownMenu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`Remove ${filterName} filter`"
|
||||
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" />
|
||||
|
||||
94
resources/js/Components/Common/TimezoneCombobox.vue
Normal file
94
resources/js/Components/Common/TimezoneCombobox.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Check, ChevronDown } from '@lucide/vue';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
ComboboxVirtualizer,
|
||||
} from '@/packages/ui/src/combobox';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import { useTimezonesQuery } from '@/utils/useTimezonesQuery';
|
||||
|
||||
// height of one row (py-1.5 text-sm → 12px padding + 20px line box)
|
||||
const ROW_HEIGHT = 32;
|
||||
|
||||
const model = defineModel<string>({ required: true });
|
||||
|
||||
defineProps<{
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const { timezones } = useTimezonesQuery();
|
||||
|
||||
const open = ref(false);
|
||||
const searchValue = ref('');
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) searchValue.value = '';
|
||||
});
|
||||
|
||||
const filteredTimezones = computed(() => {
|
||||
// Match "new york" against "America/New_York"
|
||||
const search = searchValue.value.toLowerCase().trim().replace(/\s+/g, '_');
|
||||
if (!search) return timezones.value;
|
||||
return timezones.value.filter((timezone) => timezone.toLowerCase().includes(search));
|
||||
});
|
||||
|
||||
// Reka fills the input with the selected value on open, which would filter the list down to it
|
||||
function emptySearch() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function timezoneName(timezone: string) {
|
||||
return timezone;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Combobox v-model="model" v-model:open="open" :ignore-filter="true" :disabled="disabled">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxTrigger as-child>
|
||||
<!-- aria-label replaces Reka's hardcoded "Show popup" name -->
|
||||
<Button
|
||||
:id="id"
|
||||
type="button"
|
||||
variant="input"
|
||||
:aria-label="model ? `Timezone: ${model}` : 'Timezone'"
|
||||
class="w-full justify-between font-normal">
|
||||
<span v-if="model" class="truncate">{{ model }}</span>
|
||||
<span v-else class="truncate text-muted-foreground">Select a timezone</span>
|
||||
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxList>
|
||||
<ComboboxInput
|
||||
v-model="searchValue"
|
||||
:display-value="emptySearch"
|
||||
auto-focus
|
||||
aria-label="Search timezones"
|
||||
placeholder="Search timezones..." />
|
||||
<ComboboxViewport class="p-1">
|
||||
<ComboboxVirtualizer
|
||||
v-slot="{ option }"
|
||||
:options="filteredTimezones"
|
||||
:estimate-size="ROW_HEIGHT"
|
||||
:text-content="timezoneName">
|
||||
<ComboboxItem :value="option" class="justify-between gap-2">
|
||||
<span class="truncate">{{ option }}</span>
|
||||
<Check v-if="option === model" class="h-4 w-4 shrink-0" />
|
||||
</ComboboxItem>
|
||||
</ComboboxVirtualizer>
|
||||
</ComboboxViewport>
|
||||
<div v-if="filteredTimezones.length === 0" class="px-3 py-2 text-sm text-text-tertiary">
|
||||
No timezone found.
|
||||
</div>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</template>
|
||||
@@ -7,7 +7,9 @@ const showUpgradeModal = ref(false);
|
||||
|
||||
<template>
|
||||
<UpgradeModal v-model:show="showUpgradeModal">
|
||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
||||
<slot>
|
||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
||||
</slot>
|
||||
</UpgradeModal>
|
||||
<button
|
||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
||||
|
||||
28
resources/js/Components/Common/WeekStartSelect.vue
Normal file
28
resources/js/Components/Common/WeekStartSelect.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||
import { weekdayOptions } from '@/utils/weekdays';
|
||||
import type { Weekday } from '@/packages/api/src';
|
||||
|
||||
const model = defineModel<Weekday | ''>({ required: true });
|
||||
|
||||
defineProps<{
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="model" :disabled="disabled">
|
||||
<SelectTrigger :id="id" class="w-full font-normal">
|
||||
<SelectValue placeholder="Select a week day" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="weekday in weekdayOptions"
|
||||
:key="weekday.value"
|
||||
:value="weekday.value">
|
||||
{{ weekday.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DocumentTextIcon,
|
||||
TableCellsIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { PanelLeft } from '@lucide/vue';
|
||||
import { PanelLeft, Target } from '@lucide/vue';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
canManageBilling,
|
||||
canUpdateOrganization,
|
||||
canViewClients,
|
||||
canViewGoals,
|
||||
canViewInvoices,
|
||||
canViewMembers,
|
||||
canViewProjects,
|
||||
@@ -218,6 +219,12 @@ const page = usePage<{
|
||||
"
|
||||
:href="route('reporting')">
|
||||
</NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
v-if="canViewGoals()"
|
||||
title="Goals"
|
||||
:icon="Target"
|
||||
:current="route().current('goals')"
|
||||
:href="route('goals')"></NavigationSidebarItem>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
65
resources/js/Pages/Goals.vue
Normal file
65
resources/js/Pages/Goals.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const activeTab = ref('active');
|
||||
const showArchived = computed(() => activeTab.value === 'archived');
|
||||
|
||||
// Archived goals count towards the goal limit, so the whole list is loaded and split here
|
||||
const { goals, isLoading } = useGoalsQuery('all');
|
||||
const visibleGoals = computed(() =>
|
||||
goals.value.filter((goal) => goal.is_archived === showArchived.value)
|
||||
);
|
||||
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
||||
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||
const limit = goalLimit();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Goals" data-testid="goals_view">
|
||||
<MainContainer
|
||||
class="py-5 border-b border-default-background-separator flex justify-between items-center">
|
||||
<div class="flex items-center space-x-6">
|
||||
<PageTitle :icon="Target" title="Goals"></PageTitle>
|
||||
<TabBar v-model="activeTab">
|
||||
<TabBarItem value="active">Active</TabBarItem>
|
||||
<TabBarItem value="archived">
|
||||
Archived
|
||||
<span v-if="archivedCount > 0" class="pl-1">({{ archivedCount }})</span>
|
||||
</TabBarItem>
|
||||
</TabBar>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
|
||||
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
|
||||
available in solidtime Professional.
|
||||
</UpgradeBadge>
|
||||
<SecondaryButton
|
||||
v-if="canCreateGoals() && canCreateMore"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
>Create Goal
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
</MainContainer>
|
||||
<GoalTable
|
||||
:goals="visibleGoals"
|
||||
:is-loading="isLoading"
|
||||
:can-create-more-goals="canCreateMore"
|
||||
:showing-archived="showArchived"></GoalTable>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
|
||||
@@ -14,8 +13,10 @@ import {
|
||||
useUpdateUserMutation,
|
||||
useUserQuery,
|
||||
} from '@/utils/useUserQuery';
|
||||
import type { UpdateUserBody, User } from '@/packages/api/src';
|
||||
import type { UpdateUserBody, User, Weekday } from '@/packages/api/src';
|
||||
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
|
||||
import TimezoneCombobox from '@/Components/Common/TimezoneCombobox.vue';
|
||||
import WeekStartSelect from '@/Components/Common/WeekStartSelect.vue';
|
||||
|
||||
const { user } = useUserQuery();
|
||||
const updateUser = useUpdateUserMutation();
|
||||
@@ -25,7 +26,7 @@ const resetPendingEmail = useResetUserPendingEmailMutation();
|
||||
const name = ref('');
|
||||
const email = ref('');
|
||||
const timezone = ref('');
|
||||
const weekStart = ref('');
|
||||
const weekStart = ref<Weekday | ''>('');
|
||||
|
||||
const photoBase64 = ref<string | null>(null);
|
||||
const photoPreview = ref<string | null>(null);
|
||||
@@ -166,11 +167,6 @@ function flashSaved() {
|
||||
onBeforeUnmount(() => {
|
||||
if (resendCooldownTimer) clearTimeout(resendCooldownTimer);
|
||||
});
|
||||
|
||||
const page = usePage<{
|
||||
timezones: Record<string, string>;
|
||||
weekdays: Record<string, string>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -292,42 +288,14 @@ const page = usePage<{
|
||||
<!-- Timezone -->
|
||||
<Field class="col-span-6 sm:col-span-4">
|
||||
<FieldLabel for="timezone">Timezone</FieldLabel>
|
||||
<select
|
||||
id="timezone"
|
||||
v-model="timezone"
|
||||
name="timezone"
|
||||
required
|
||||
:disabled="!isUserLoaded"
|
||||
class="block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a Timezone</option>
|
||||
<option
|
||||
v-for="(timezoneTranslated, timezoneValue) in page.props.timezones"
|
||||
:key="timezoneValue"
|
||||
:value="timezoneValue">
|
||||
{{ timezoneTranslated }}
|
||||
</option>
|
||||
</select>
|
||||
<TimezoneCombobox id="timezone" v-model="timezone" :disabled="!isUserLoaded" />
|
||||
<FieldError v-if="fieldErrors.timezone">{{ fieldErrors.timezone }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<!-- Week start -->
|
||||
<Field class="col-span-6 sm:col-span-4">
|
||||
<FieldLabel for="week_start">Start of the week</FieldLabel>
|
||||
<select
|
||||
id="week_start"
|
||||
v-model="weekStart"
|
||||
name="week_start"
|
||||
required
|
||||
:disabled="!isUserLoaded"
|
||||
class="block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a week day</option>
|
||||
<option
|
||||
v-for="(weekdayTranslated, weekdayValue) in page.props.weekdays"
|
||||
:key="weekdayValue"
|
||||
:value="weekdayValue">
|
||||
{{ weekdayTranslated }}
|
||||
</option>
|
||||
</select>
|
||||
<WeekStartSelect id="week_start" v-model="weekStart" :disabled="!isUserLoaded" />
|
||||
<FieldError v-if="fieldErrors.week_start">{{ fieldErrors.week_start }}</FieldError>
|
||||
</Field>
|
||||
</template>
|
||||
|
||||
@@ -108,6 +108,19 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
|
||||
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
||||
export type Report = ReportIndexResponse['data'][0];
|
||||
|
||||
export type GoalIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getGoals'>;
|
||||
export type Goal = GoalIndexResponse['data'][0];
|
||||
export type GoalFilters = Goal['filters'];
|
||||
export type GoalProgress = Goal['progress'];
|
||||
export type GoalComparison = Goal['comparison'];
|
||||
export type GoalPeriod = Goal['period'];
|
||||
export type GoalType = Goal['type'];
|
||||
export type GoalWeekStart = Goal['week_start'];
|
||||
export type Weekday = GoalWeekStart;
|
||||
export type GoalStatus = GoalProgress['status'];
|
||||
export type CreateGoalBody = ZodiosBodyByAlias<SolidTimeApi, 'createGoal'>;
|
||||
export type UpdateGoalBody = ZodiosBodyByAlias<SolidTimeApi, 'updateGoal'>;
|
||||
|
||||
export type ApiTokenIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getApiTokens'>;
|
||||
|
||||
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
|
||||
|
||||
@@ -412,6 +412,94 @@ const ProjectMemberUpdateRequest = z
|
||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||
.partial()
|
||||
.passthrough();
|
||||
const GoalComparison = z.enum(['at_least', 'less_than']);
|
||||
const GoalPeriod = z.enum(['day', 'week', 'month']);
|
||||
const GoalType = z.enum(['personal', 'organization']);
|
||||
const Weekday = z.enum([
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const GoalStatus = z.enum(['in_progress', 'achieved', 'on_track', 'exceeded']);
|
||||
const GoalResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: GoalType,
|
||||
comparison: GoalComparison,
|
||||
target_seconds: z.number().int(),
|
||||
period: GoalPeriod,
|
||||
member_id: z.union([z.string(), z.null()]),
|
||||
member_name: z.union([z.string(), z.null()]),
|
||||
timezone: z.string(),
|
||||
week_start: Weekday,
|
||||
is_archived: z.boolean(),
|
||||
filters: z
|
||||
.object({
|
||||
member_ids: z.union([z.array(z.string()), z.null()]),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
|
||||
client_ids: z.union([z.array(z.string()), z.null()]),
|
||||
billable: z.union([z.boolean(), z.null()]),
|
||||
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
progress: z
|
||||
.object({
|
||||
period_start: z.string(),
|
||||
period_end: z.string(),
|
||||
tracked_seconds: z.number().int(),
|
||||
status: GoalStatus,
|
||||
})
|
||||
.passthrough(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const GoalFiltersRequest = z
|
||||
.object({
|
||||
member_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]).optional(),
|
||||
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
billable: z.union([z.boolean(), z.null()]).optional(),
|
||||
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const GoalStoreRequest = z
|
||||
.object({
|
||||
name: z.string().max(255),
|
||||
type: GoalType,
|
||||
comparison: GoalComparison,
|
||||
target_seconds: z.number().int().gte(1).lte(2147483647),
|
||||
period: GoalPeriod,
|
||||
member_id: z.union([z.string().uuid(), z.null()]).optional(),
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
week_start: z.union([Weekday, z.null()]).optional(),
|
||||
filters: GoalFiltersRequest.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const GoalUpdateRequest = z
|
||||
.object({
|
||||
name: z.string().max(255),
|
||||
comparison: GoalComparison,
|
||||
target_seconds: z.number().int().gte(1).lte(2147483647),
|
||||
period: GoalPeriod,
|
||||
timezone: z.string(),
|
||||
week_start: Weekday,
|
||||
is_archived: z.boolean(),
|
||||
filters: GoalFiltersRequest,
|
||||
})
|
||||
.partial()
|
||||
.passthrough();
|
||||
const ReportResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -439,15 +527,6 @@ const TimeEntryAggregationType = z.enum([
|
||||
'type',
|
||||
]);
|
||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||
const Weekday = z.enum([
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
||||
const ReportStoreRequest = z
|
||||
.object({
|
||||
@@ -782,10 +861,18 @@ export const schemas = {
|
||||
ProjectMemberResource,
|
||||
ProjectMemberStoreRequest,
|
||||
ProjectMemberUpdateRequest,
|
||||
GoalComparison,
|
||||
GoalPeriod,
|
||||
GoalType,
|
||||
Weekday,
|
||||
GoalStatus,
|
||||
GoalResource,
|
||||
GoalFiltersRequest,
|
||||
GoalStoreRequest,
|
||||
GoalUpdateRequest,
|
||||
ReportResource,
|
||||
TimeEntryAggregationType,
|
||||
TimeEntryAggregationTypeInterval,
|
||||
Weekday,
|
||||
TimeEntryRoundingType,
|
||||
ReportStoreRequest,
|
||||
DetailedReportResource,
|
||||
@@ -833,6 +920,13 @@ const endpoints = makeApi([
|
||||
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
|
||||
),
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/time-zones',
|
||||
alias: 'getTimezones',
|
||||
requestFormat: 'json',
|
||||
response: z.array(z.object({ key: z.string() }).passthrough()),
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations',
|
||||
@@ -3182,6 +3276,254 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/goals',
|
||||
alias: 'getGoals',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'Query',
|
||||
schema: z.union([GoalType, z.null()]).optional(),
|
||||
},
|
||||
{
|
||||
name: 'archived',
|
||||
type: 'Query',
|
||||
schema: z.union([z.enum(['true', 'false', 'all']), z.null()]).optional(),
|
||||
},
|
||||
{
|
||||
name: 'page',
|
||||
type: 'Query',
|
||||
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(GoalResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/goals',
|
||||
alias: 'createGoal',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: GoalStoreRequest,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: GoalResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/goals/:goal',
|
||||
alias: 'getGoal',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
{
|
||||
name: 'goal',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: GoalResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/goals/:goal',
|
||||
alias: 'updateGoal',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: GoalUpdateRequest,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
{
|
||||
name: 'goal',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: GoalResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/goals/:goal',
|
||||
alias: 'deleteGoal',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
{
|
||||
name: 'goal',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/reports',
|
||||
|
||||
@@ -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