add frontend format support for currencies, add currencies endpoint

This commit is contained in:
Gregor Vostrak
2025-05-08 17:25:36 +02:00
parent 8b950d99d6
commit ed32c6b217
24 changed files with 352 additions and 128 deletions

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
{
/**
* Get all currencies
*
* @response array{code: string, name: string, symbol: string}[]
* @operationId getCurrencies
*/
public function index(): JsonResponse
{
$currencyService = app(CurrencyService::class);
$currencies = array_values(array_map(
fn ($currency) => [
'code' => $currency->getCurrencyCode(),
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);
}
}

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Organization;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -34,7 +40,9 @@ class OrganizationResource extends BaseResource
*/
public function toArray(Request $request): array
{
return [
$currencyService = app(CurrencyService::class);
return [
/** @var string $id ID */
'id' => $this->resource->id,
/** @var string $name Name */
@@ -47,15 +55,17 @@ class OrganizationResource extends BaseResource
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $number_format Number format */
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->number_format->value,
/** @var string $currency_format Currency format */
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value,
/** @var string $date_format Date format */
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->date_format->value,
/** @var string $interval_format Interval format */
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value,
/** @var string $time_format Time format */
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->time_format->value,
];
}

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -64,6 +70,8 @@ class DetailedWithDataReportResource extends BaseResource
*/
public function toArray(Request $request): array
{
$currencyService = app(CurrencyService::class);
return [
/** @var string $name Name */
'name' => $this->resource->name,
@@ -73,16 +81,18 @@ class DetailedWithDataReportResource extends BaseResource
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
/** @var string $number_format Number format */
'number_format' => $this->resource->organization->number_format,
/** @var string $currency_format Currency format */
'currency_format' => $this->resource->organization->currency_format,
/** @var string $date_format Date format */
'date_format' => $this->resource->organization->date_format,
/** @var string $interval_format Interval format */
'interval_format' => $this->resource->organization->interval_format,
/** @var string $time_format Time format */
'time_format' => $this->resource->organization->time_format,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->organization->currency_format->value,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency),
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->organization->date_format->value,
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->organization->interval_format->value,
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,

View File

@@ -1,7 +1,8 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money';
import { formatCents, getOrganizationCurrencySymbol } from '../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -61,6 +62,12 @@ test('test that updating project member billable rate works for existing time en
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100, 'EUR'))
.getByText(formatCents(
newBillableRate * 100,
'EUR',
'symbol-before' as CurrencyFormat,
'€',
'space-point'
))
).toBeVisible();
});

View File

@@ -1,7 +1,8 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money';
import { formatCents, getOrganizationCurrencySymbol } from '../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -131,7 +132,13 @@ test('test that updating billable rate works with existing time entries', async
page
.getByRole('row')
.first()
.getByText(formatCents(newBillableRate * 100, 'EUR'))
.getByText(formatCents(
newBillableRate * 100,
'EUR',
'symbol-before' as CurrencyFormat,
'€',
'space-point'
))
).toBeVisible();
});

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
memberName: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the organization'
}}</strong

View File

@@ -1,26 +1,27 @@
<script setup lang="ts">
import type { Member } from '@/packages/api/src';
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import {computed, ref} from 'vue';
import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
const props = defineProps<{
member: Member;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false);
@@ -35,15 +36,12 @@ async function invitePlaceholder(id: string) {
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.invitePlaceholder(
undefined,
{
params: {
organization: organizationId,
member: id,
},
}
),
api.invitePlaceholder(undefined, {
params: {
organization: organizationId,
member: id,
},
}),
'Member invited successfully',
'Error inviting member'
);
@@ -52,8 +50,7 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
});
</script>
<template>
@@ -75,7 +72,10 @@ const userHasValidMailAddress = computed(() => {
member.billable_rate
? formatCents(
member.billable_rate,
getOrganizationCurrencyString()
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
@@ -101,21 +101,26 @@ const userHasValidMailAddress = computed(() => {
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite</SecondaryButton
>
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="showMakeMemberPlaceholderModal = true"
></MemberMoreOptionsDropdown>
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
</TableRow>
</template>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
}>();
@@ -27,7 +31,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' none.'
}}</strong

View File

@@ -47,12 +47,17 @@ function archiveProject() {
});
}
const organization = inject<ComputedRef<Organization>>('organization');
const billableRateInfo = computed(() => {
if (props.project.is_billable) {
if (props.project.billable_rate) {
return formatCents(
props.project.billable_rate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
} else {
return 'Default Rate';
@@ -63,7 +68,6 @@ const billableRateInfo = computed(() => {
const showEditProjectModal = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
memberName?: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the project'
}}</strong

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { storeToRefs } from 'pinia';
import TableRow from '@/Components/TableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
@@ -10,10 +10,14 @@ import { formatCents } from '@/packages/ui/src/utils/money';
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';
const props = defineProps<{
projectMember: ProjectMember;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function deleteProjectMember() {
useProjectMembersStore().deleteProjectMember(
props.projectMember.project_id,
@@ -51,7 +55,10 @@ const showEditModal = ref(false);
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}

View File

@@ -8,7 +8,11 @@ import {
import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import {
formatHumanReadableDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
@@ -29,17 +33,13 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { computed, onMounted, ref, inject, type ComputedRef } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import {
type AggregatedTimeEntriesQueryParams,
type CreateReportBodyProperties,
api,
type CreateReportBodyProperties,
type Organization,
} from '@/packages/api/src';
import {
@@ -52,6 +52,7 @@ import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification';
import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects';
const { handleApiRequestNotifications } = useNotificationsStore();
@@ -213,7 +214,6 @@ async function downloadExport(format: ExportFormat) {
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
import { useProjectsStore } from '@/utils/useProjects';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
@@ -450,10 +450,8 @@ const tableData = computed(() => {
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
:type="aggregatedTableTimeEntries.grouped_type"
:entry="entry"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
@@ -475,7 +473,10 @@ const tableData = computed(() => {
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}

View File

@@ -57,7 +57,13 @@ const organization = inject<ComputedRef<Organization>>('organization');
}}
</div>
<div class="justify-end pr-6 flex items-center">
{{ entry.cost ? formatCents(entry.cost, props.currency) : '--' }}
{{ entry.cost ? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) : '--' }}
</div>
</div>
<div

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
defineProps<{
title: string;
value: string;
value?: string;
}>();
</script>
@@ -10,7 +10,7 @@ defineProps<{
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value }}
{{ value ?? '--' }}
</dd>
</div>
</template>

View File

@@ -283,7 +283,10 @@ const option = computed(() => {
totalWeeklyBillableAmount
? formatCents(
totalWeeklyBillableAmount.value,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
" />

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, inject, type ComputedRef } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import {
@@ -33,9 +33,12 @@ import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src';
import { formatCents } from '../packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '../utils/money';
import type { Organization } from '@/packages/api/src';
const { projects } = storeToRefs(useProjectsStore());
const organization = inject<ComputedRef<Organization>>('organization');
const project = computed(() => {
return (
projects.value.find(
@@ -112,7 +115,10 @@ const shownTasks = computed(() => {
{{
formatCents(
project?.billable_rate ?? 0,
getOrganizationCurrencyString()
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
}}
/ h
@@ -145,15 +151,11 @@ const shownTasks = computed(() => {
<div
class="w-full items-center flex justify-between">
<div class="pl-6">
<TabBar
v-model="activeTab"
>
<TabBarItem
value="active"
<TabBar v-model="activeTab">
<TabBarItem value="active"
>Active
</TabBarItem>
<TabBarItem
value="done"
<TabBarItem value="done"
>Done
</TabBarItem>
</TabBar>
@@ -185,11 +187,11 @@ const shownTasks = computed(() => {
Add Member
</SecondaryButton>
<ProjectMemberCreateModal
v-model:show="
createProjectMember
"
v-model:show="createProjectMember"
:project-id="projectId"
:existing-members="projectMembers"></ProjectMemberCreateModal>
:existing-members="
projectMembers
"></ProjectMemberCreateModal>
</template>
</CardTitle>
<Card>

View File

@@ -7,6 +7,7 @@ import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import { computed, onMounted, provide, ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
@@ -55,11 +56,21 @@ const reportNumberFormat = computed(() => {
return sharedReportResponseData.value?.number_format;
});
const reportCurrencyFormat = computed(() => {
return (sharedReportResponseData.value?.currency_format ?? 'symbol-before') as CurrencyFormat;
});
const reportCurrencySymbol = computed(() => {
return sharedReportResponseData.value?.currency_symbol;
});
provide(
'organization',
computed(() => ({
'number_format': reportNumberFormat.value,
'interval_format': reportIntervalFormat.value,
'currency_format': reportCurrencyFormat.value,
'currency_symbol': reportCurrencySymbol.value,
}))
);
@@ -217,10 +228,8 @@ onMounted(async () => {
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="reportCurrency"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
:currency-format="reportCurrencyFormat"
:entry="entry"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
@@ -241,7 +250,9 @@ onMounted(async () => {
{{
formatCents(
aggregatedTableTimeEntries.cost,
reportCurrency
reportCurrency,
reportCurrencyFormat,
reportCurrencySymbol,
)
}}
</div>

View File

@@ -15,11 +15,11 @@ import {
} from '@/Components/ui/select';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import type {
CurrencyFormat,
DateFormat,
TimeFormat,
IntervalFormat,
} from '@/packages/ui/src/utils/time';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import type { NumberFormat } from '@/packages/ui/src/utils/number';
interface FormValues {

View File

@@ -167,7 +167,10 @@ export type ApiTokenIndexResponse = ZodiosResponseByAlias<
'getApiTokens'
>;
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
export type CreateApiTokenBody = ZodiosBodyByAlias<
SolidTimeApi,
'createApiToken'
>;
export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<
@@ -175,6 +178,21 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<
'getInvoice'
>;
export type UpdateInvoiceSettings = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoiceSettings'
>;
export type CreateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'createInvoice'
>;
export type UpdateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoice'
>;
const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api };

View File

@@ -130,7 +130,7 @@ const InvoiceEntryResource = z
name: z.string(),
description: z.union([z.string(), z.null()]),
unit_price: z.number().int(),
quantity: z.string(),
quantity: z.number(),
order_index: z.number().int(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
@@ -293,21 +293,6 @@ const MemberMergeIntoRequest = z
.object({ member_id: z.string() })
.partial()
.passthrough();
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
number_format: z.string(),
currency_format: z.string(),
date_format: z.string(),
interval_format: z.string(),
time_format: z.string(),
})
.passthrough();
const NumberFormat = z.enum([
'point-comma',
'comma-point',
@@ -338,6 +323,22 @@ const IntervalFormat = z.enum([
'hours-minutes-seconds-colon-separated',
]);
const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
})
.passthrough();
const OrganizationUpdateRequest = z
.object({
name: z.string().max(255),
@@ -524,11 +525,12 @@ const DetailedWithDataReportResource = z
description: z.union([z.string(), z.null()]),
public_until: z.union([z.string(), z.null()]),
currency: z.string(),
number_format: z.string(),
currency_format: z.string(),
date_format: z.string(),
interval_format: z.string(),
time_format: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
currency_symbol: z.string(),
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
properties: z
.object({
group: z.string(),
@@ -774,12 +776,12 @@ export const schemas = {
Role,
MemberUpdateRequest,
MemberMergeIntoRequest,
OrganizationResource,
NumberFormat,
CurrencyFormat,
DateFormat,
IntervalFormat,
TimeFormat,
OrganizationResource,
OrganizationUpdateRequest,
ProjectResource,
ProjectStoreRequest,
@@ -817,7 +819,9 @@ const endpoints = makeApi([
path: '/v1/countries',
alias: 'getCountries',
requestFormat: 'json',
response: z.string(),
response: z.array(
z.object({ code: z.string(), name: z.string() }).passthrough()
),
errors: [
{
status: 401,
@@ -826,6 +830,21 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/currencies',
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
),
},
{
method: 'get',
path: '/v1/organizations/:organization',

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import {
formatCents,
getOrganizationCurrencySymbol,
} from '@/packages/ui/src/utils/money';
import { ref, watch } from 'vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { ref, watch, inject, type ComputedRef } from 'vue';
import { useFocus } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
name: string;
@@ -13,6 +11,8 @@ const props = defineProps<{
currency: string;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const model = defineModel<number | null>({
default: null,
});
@@ -58,9 +58,15 @@ function updateRate(value: string) {
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0, props.currency);
const formattedValue = formatCents(
modelValue ?? 0,
props.currency,
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
return formattedValue
.replace(getOrganizationCurrencySymbol(props.currency), '')
?.replace(organization?.value?.currency_symbol ?? '', '')
.trim();
}

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { formatCents } from '@/packages/ui/src/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
newBillableRate?: number | null;
projectName: string;
@@ -26,7 +30,13 @@ defineEmits<{
The billable rate of {{ projectName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate, currency)
? formatCents(
newBillableRate,
currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the organization member'
}}</strong
>.

View File

@@ -1,12 +1,52 @@
function formatMoney(amount: number, currency: string) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency,
}).format(amount);
import { formatNumber, type NumberFormat } from './number';
export type CurrencyFormat =
| 'iso-code-before-with-space'
| 'iso-code-after-with-space'
| 'symbol-before'
| 'symbol-after'
| 'symbol-before-with-space'
| 'symbol-after-with-space';
function formatMoney(
amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
const formattedAmount = formatNumber(amount, numberFormat);
switch (format) {
case 'iso-code-before-with-space':
return `${currency} ${formattedAmount}`;
case 'iso-code-after-with-space':
return `${formattedAmount} ${currency}`;
case 'symbol-before':
return `${currencySymbol}${formattedAmount}`;
case 'symbol-after':
return `${formattedAmount}${currencySymbol}`;
case 'symbol-before-with-space':
return `${currencySymbol} ${formattedAmount}`;
case 'symbol-after-with-space':
return `${formattedAmount} ${currencySymbol}`;
}
}
export function formatCents(amount: number, currency: string) {
return formatMoney(amount / 100, currency);
export function formatCents(
amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
return formatMoney(
amount / 100,
currency,
format,
currencySymbol,
numberFormat
);
}
export function getOrganizationCurrencySymbol(currency: string) {

View File

@@ -173,6 +173,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
});
});
Route::get('/currencies', [\App\Http\Controllers\Api\V1\CurrencyController::class, 'index'])->name('currencies.index');
// Public routes
Route::name('public.')->prefix('/public')->group(static function (): void {
Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');