mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 17:23:16 +01:00
add billable rate restriction for free organizations
This commit is contained in:
committed by
Constantin Graf
parent
deb0c26e32
commit
5a9c8a8bf7
@@ -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: () => {},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user