mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-17 02:03:16 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user