Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 5010a5be3a
commit 38e33b318e
83 changed files with 7702 additions and 105 deletions

View File

@@ -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" />

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;
}

View File

@@ -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" />

View 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>

View File

@@ -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"

View 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>