mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
add frontend format support for currencies, add currencies endpoint
This commit is contained in:
35
app/Http/Controllers/Api/V1/CurrencyController.php
Normal file
35
app/Http/Controllers/Api/V1/CurrencyController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -283,7 +283,10 @@ const option = computed(() => {
|
||||
totalWeeklyBillableAmount
|
||||
? formatCents(
|
||||
totalWeeklyBillableAmount.value,
|
||||
getOrganizationCurrencyString()
|
||||
getOrganizationCurrencyString(),
|
||||
organization?.currency_format,
|
||||
organization?.currency_symbol,
|
||||
organization?.number_format
|
||||
)
|
||||
: '--'
|
||||
" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
>.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user