mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 16:53:17 +01:00
add billable rate restriction for free organizations
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '') }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,7 +38,9 @@ import {
|
||||
canViewReport,
|
||||
canViewTags,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||
import type { User } from '@/types/models';
|
||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
@@ -91,6 +93,14 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
||||
|
||||
provide('organization', organization);
|
||||
|
||||
const showBillableRatesUpgradeModal = ref(false);
|
||||
provide(billableRatesLockKey, {
|
||||
locked: computed(() => !canUseBillableRates()),
|
||||
requestUpgrade: () => {
|
||||
showBillableRatesUpgradeModal.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
provide(upgradeCtaKey, {
|
||||
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
|
||||
goToBilling: () => router.visit('/billing'),
|
||||
@@ -392,4 +402,5 @@ const page = usePage<{
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
<CommandPaletteProvider></CommandPaletteProvider>
|
||||
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { canUseBillableRates } from '@/utils/billing';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
|
||||
return task.project_id === projectId && task.is_done;
|
||||
});
|
||||
});
|
||||
|
||||
// Note: The page sits above the app layout that provides the lock, so it can not inject it
|
||||
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
|
||||
</li>
|
||||
</ol>
|
||||
<div class="px-4 space-x-1">
|
||||
<Badge v-if="project?.billable_rate">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked"
|
||||
>Billable Rates</UpgradeLockedBadge
|
||||
>
|
||||
<Badge v-else-if="project?.billable_rate">
|
||||
{{ billableRateFormatted }}
|
||||
/ h
|
||||
</Badge>
|
||||
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
||||
<Badge
|
||||
v-if="
|
||||
project?.is_billable && !project?.billable_rate && !billableRatesLocked
|
||||
">
|
||||
Default Rate
|
||||
</Badge>
|
||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||
|
||||
@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
|
||||
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { fetchOrganization, updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
const organizationBody = ref<UpdateOrganizationBody>({
|
||||
name: '',
|
||||
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
|
||||
<Checkbox
|
||||
v-if="organization"
|
||||
id="organizationShowBillableRatesToEmployees"
|
||||
v-model:checked="
|
||||
organizationBody.employees_can_see_billable_rates
|
||||
"></Checkbox>
|
||||
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
||||
:disabled="billableRatesLocked"></Checkbox>
|
||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||
>Show Billable Rates to Employees</FieldLabel
|
||||
>
|
||||
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
||||
>Upgrade</PrimaryButton
|
||||
>
|
||||
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
|
||||
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
||||
const OrganizationResource = z
|
||||
.object({
|
||||
can_use_billable_rates: z.boolean().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
is_personal: z.boolean(),
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '..';
|
||||
import UpgradeLockedField from './UpgradeLockedField.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const { locked } = useBillableRatesLock();
|
||||
|
||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(billableRateInput, { initialValue: props.focus });
|
||||
|
||||
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<UpgradeLockedField v-if="locked" />
|
||||
<NumberField
|
||||
v-else
|
||||
:id="name"
|
||||
ref="billableRateInput"
|
||||
:model-value="formatValue(model)"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,7 +11,13 @@ import {
|
||||
} from '@/packages/ui/src/tooltip';
|
||||
const active = defineModel<boolean>({ default: false });
|
||||
const emit = defineEmits(['changed']);
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
function toggleBillable() {
|
||||
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||
if (billableRatesLocked.value && !active.value) {
|
||||
requestUpgrade();
|
||||
return;
|
||||
}
|
||||
active.value = !active.value;
|
||||
emit('changed', active.value);
|
||||
}
|
||||
@@ -26,7 +33,12 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
const tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));
|
||||
const tooltipLabel = computed(() => {
|
||||
if (billableRatesLocked.value && !active.value) {
|
||||
return 'Billable rates require an upgrade';
|
||||
}
|
||||
return active.value ? 'Billable' : 'Non Billable';
|
||||
});
|
||||
|
||||
const iconColorClasses = computed(() => {
|
||||
if (active.value) {
|
||||
|
||||
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const { requestUpgrade } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="billable_rates_locked"
|
||||
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click.prevent.stop="requestUpgrade">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
|
||||
<span class="truncate"><slot>Billable rates</slot></span>
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -10,21 +10,27 @@ import {
|
||||
} from '@/packages/ui/src/tooltip';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
}>();
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
type RateType = 'default-rate' | 'custom-rate';
|
||||
|
||||
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||
const rateType = ref<RateType>('default-rate');
|
||||
const originallyBillable = ref(false);
|
||||
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
|
||||
|
||||
const billableRate = defineModel<number | null>('billableRate');
|
||||
const isBillable = defineModel<boolean>('isBillable');
|
||||
|
||||
onMounted(() => {
|
||||
originallyBillable.value = isBillable.value === true;
|
||||
if (isBillable.value === true) {
|
||||
billableDefault.value = 'billable';
|
||||
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
||||
@@ -66,6 +72,9 @@ const displayedRate = computed({
|
||||
});
|
||||
|
||||
const billableDescription = computed(() => {
|
||||
if (billableFlagLocked.value) {
|
||||
return 'Your organization’s plan does not allow enabling billable projects.';
|
||||
}
|
||||
if (billableDefault.value === 'non-billable') {
|
||||
return 'New time entries for this project will not be marked billable by default.';
|
||||
}
|
||||
@@ -84,14 +93,19 @@ const emit = defineEmits(['submit']);
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="non-billable">Non-billable</SelectItem>
|
||||
<SelectItem value="billable">Billable</SelectItem>
|
||||
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<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="currency"
|
||||
name="billableRate" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Select v-model="rateType">
|
||||
<SelectTrigger id="billableRateType">
|
||||
<SelectValue />
|
||||
|
||||
@@ -16,12 +16,14 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -189,7 +191,9 @@ const billableProxy = computed({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="true" :disabled="billableRatesLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
@@ -64,6 +65,7 @@ watch(show, (value) => {
|
||||
});
|
||||
|
||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
watch(
|
||||
() => props.timeEntry,
|
||||
@@ -134,6 +136,11 @@ async function deleteEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
// Organizations with locked billable rates can not make a non-billable time entry billable
|
||||
const billableOptionLocked = computed(
|
||||
() => billableRatesLocked.value && !props.timeEntry?.billable
|
||||
);
|
||||
|
||||
const billableProxy = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||
@@ -245,7 +252,9 @@ const typeProxy = computed({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="true" :disabled="billableOptionLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -20,9 +20,12 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -263,7 +266,9 @@ const showBreakWarning = computed(
|
||||
<SelectItem value="do-not-update">
|
||||
Keep current billable status
|
||||
</SelectItem>
|
||||
<SelectItem value="billable">Billable</SelectItem>
|
||||
<SelectItem value="billable" :disabled="billableRatesLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="non-billable">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
|
||||
const { requestUpgrade } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="billable_rates_locked_badge"
|
||||
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
|
||||
@click.prevent.stop="requestUpgrade">
|
||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
|
||||
</button>
|
||||
</template>
|
||||
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import { Button } from './Buttons';
|
||||
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
|
||||
const { requestUpgrade } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
--
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-text-tertiary hover:text-text-primary"
|
||||
aria-label="Upgrade to unlock billable rates"
|
||||
data-testid="billable_rates_locked_button"
|
||||
@click.prevent.stop="requestUpgrade">
|
||||
<LockClosedIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -21,6 +21,8 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type { ActivityPeriod } from './FullCalendar/activityTypes';
|
||||
export { cn } from './utils/cn';
|
||||
export { useCssVariable } from './utils/useCssVariable';
|
||||
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
export type { BillableRatesLock } from './utils/useBillableRatesLock';
|
||||
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
|
||||
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||
|
||||
@@ -165,6 +167,8 @@ import TabBar from './TabBar/TabBar.vue';
|
||||
import TabBarItem from './TabBar/TabBarItem.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import UpgradeBadge from './UpgradeBadge.vue';
|
||||
import UpgradeLockedBadge from './UpgradeLockedBadge.vue';
|
||||
import UpgradeLockedValue from './UpgradeLockedValue.vue';
|
||||
import UpgradeModal from './UpgradeModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
|
||||
@@ -310,5 +314,7 @@ export {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
UpgradeBadge,
|
||||
UpgradeLockedBadge,
|
||||
UpgradeLockedValue,
|
||||
UpgradeModal,
|
||||
};
|
||||
|
||||
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
|
||||
|
||||
export interface BillableRatesLock {
|
||||
locked: ComputedRef<boolean>;
|
||||
requestUpgrade: () => void;
|
||||
}
|
||||
|
||||
export const billableRatesLockKey: InjectionKey<BillableRatesLock> = Symbol('billableRatesLock');
|
||||
|
||||
/**
|
||||
* Whether billable rates are locked behind a plan upgrade for the current organization.
|
||||
*
|
||||
* The app layout provides the lock state and opens the upgrade dialog. Without a
|
||||
* provider (e.g. public report views or other consumers of this package) billable
|
||||
* rates count as unlocked.
|
||||
*/
|
||||
export function useBillableRatesLock(): BillableRatesLock {
|
||||
return inject(billableRatesLockKey, {
|
||||
locked: computed(() => false),
|
||||
requestUpgrade: () => {},
|
||||
});
|
||||
}
|
||||
@@ -98,3 +98,13 @@ export function isAllowedToPerformPremiumAction() {
|
||||
(isBillingActivated() && isInTrial())
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseBillableRates() {
|
||||
const page = usePage<{
|
||||
billing: {
|
||||
can_use_billable_rates?: boolean;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
return !isBillingActivated() || page.props.billing?.can_use_billable_rates !== false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user