add billable rate restriction for free organizations

This commit is contained in:
Gregor Vostrak
2026-10-13 14:59:12 +02:00
parent 9e6013eaf1
commit ba2b37f264
55 changed files with 1207 additions and 103 deletions

View File

@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -71,6 +72,8 @@ function saveWithChecks() {
}
}
const { locked: billableRatesLocked } = useBillableRatesLock();
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
name="memberBillableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import {
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -89,8 +93,9 @@ const userHasValidMailAddress = computed(() => {
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="member.billable_rate">
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="member.billable_rate">
{{
formatCents(
member.billable_rate,

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -15,6 +16,7 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div
class="whitespace-nowrap min-w-0 px-3 py-4 flex items-center text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else class="text-text-tertiary">No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div
class="whitespace-nowrap px-3 py-4 flex items-center text-sm text-text-primary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
@@ -130,8 +136,9 @@ const showEditProjectModal = ref(false);
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import TableRow from '@/Components/TableRow.vue';
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
const props = defineProps<{
projectMember: ProjectMember;
@@ -33,6 +35,8 @@ const member = computed(() => {
return members.value.find((member) => member.id === props.projectMember.member_id);
});
const showEditModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -47,18 +51,21 @@ const showEditModal = ref(false);
{{ member?.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else>
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member?.role ?? '') }}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, LockClosedIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
@@ -12,6 +13,7 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src/Buttons';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -35,6 +37,8 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
const startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true });
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const emit = defineEmits<{
submit: [];
}>();
@@ -141,7 +145,17 @@ async function createTag(name: string) {
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">
<Button
v-if="billableRatesLocked"
variant="outline"
size="sm"
data-testid="billable_filter_locked"
@click="requestUpgrade">
<BillableIcon class="h-4 text-text-quaternary" />
<span class="text-text-secondary">Billable</span>
<LockClosedIcon class="!w-3 text-text-tertiary" />
</Button>
<Select v-else v-model="billable" @update:model-value="emit('submit')">
<SelectTrigger
size="sm"
variant="outline"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
ChartBarIcon,
ArrowDownTrayIcon,
@@ -295,6 +297,8 @@ const tableData = computed(() => {
};
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -450,17 +454,20 @@ const tableData = computed(() => {
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
</template>

View File

@@ -2,6 +2,8 @@
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { ref, inject, type ComputedRef } from 'vue';
import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
@@ -27,6 +29,8 @@ const props = defineProps<{
const expanded = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -53,17 +57,20 @@ const organization = inject<ComputedRef<Organization>>('organization');
}}
</div>
<div v-if="showCost" class="justify-end pr-6 flex items-center">
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
<div

View File

@@ -9,7 +9,7 @@ defineProps<{
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium">
{{ value ?? '--' }}
<slot>{{ value ?? '--' }}</slot>
</dd>
</div>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
staleTime: 1000 * 30, // 30 seconds
});
const { locked: billableRatesLocked } = useBillableRatesLock();
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => {
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
},
});
},
enabled: computed(() => !!organizationId.value),
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
staleTime: 1000 * 30, // 30 seconds
});
@@ -270,7 +274,11 @@ const option = computed(() => {
)
: '--'
" />
<StatCard v-if="billableRatesLocked" title="Billable Amount">
<UpgradeLockedValue />
</StatCard>
<StatCard
v-else
title="Billable Amount"
:value="
totalWeeklyBillableAmount

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import {
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -190,17 +194,20 @@ const showBillableRate = computed(() => {
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
</template>