diff --git a/app/Http/Controllers/Api/V1/CurrencyController.php b/app/Http/Controllers/Api/V1/CurrencyController.php new file mode 100644 index 00000000..56c595c2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CurrencyController.php @@ -0,0 +1,35 @@ + [ + 'code' => $currency->getCurrencyCode(), + 'name' => $currency->getName(), + 'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()), + ], + ISOCurrencyProvider::getInstance()->getAvailableCurrencies() + )); + + return response()->json($currencies); + } +} diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index 5233a2fd..5672c999 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -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, ]; } diff --git a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php index ee80d37f..3129f45f 100644 --- a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php @@ -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, diff --git a/e2e/project-members.spec.ts b/e2e/project-members.spec.ts index 7302a350..b537905b 100644 --- a/e2e/project-members.spec.ts +++ b/e2e/project-members.spec.ts @@ -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(); }); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index d989630f..ea855190 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -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(); }); diff --git a/resources/js/Components/Common/Member/MemberBillableRateModal.vue b/resources/js/Components/Common/Member/MemberBillableRateModal.vue index f06d57b0..4486a613 100644 --- a/resources/js/Components/Common/Member/MemberBillableRateModal.vue +++ b/resources/js/Components/Common/Member/MemberBillableRateModal.vue @@ -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>('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' }} -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>('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'); -}) - +}); diff --git a/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue b/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue index b11d198c..4ab82acc 100644 --- a/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue +++ b/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue @@ -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>('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.' }}>('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>('organization'); diff --git a/resources/js/Components/Dashboard/ThisWeekOverview.vue b/resources/js/Components/Dashboard/ThisWeekOverview.vue index 6ae33f91..f9a1e9e3 100644 --- a/resources/js/Components/Dashboard/ThisWeekOverview.vue +++ b/resources/js/Components/Dashboard/ThisWeekOverview.vue @@ -283,7 +283,10 @@ const option = computed(() => { totalWeeklyBillableAmount ? formatCents( totalWeeklyBillableAmount.value, - getOrganizationCurrencyString() + getOrganizationCurrencyString(), + organization?.currency_format, + organization?.currency_symbol, + organization?.number_format ) : '--' " /> diff --git a/resources/js/Pages/ProjectShow.vue b/resources/js/Pages/ProjectShow.vue index acb84e59..7aac8776 100644 --- a/resources/js/Pages/ProjectShow.vue +++ b/resources/js/Pages/ProjectShow.vue @@ -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>('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(() => {
- - + Active - Done @@ -185,11 +187,11 @@ const shownTasks = computed(() => { Add Member + :existing-members=" + projectMembers + "> diff --git a/resources/js/Pages/SharedReport.vue b/resources/js/Pages/SharedReport.vue index 1157764a..3a12bb1f 100644 --- a/resources/js/Pages/SharedReport.vue +++ b/resources/js/Pages/SharedReport.vue @@ -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 - "> + :currency-format="reportCurrencyFormat" + :entry="entry">
@@ -241,7 +250,9 @@ onMounted(async () => { {{ formatCents( aggregatedTableTimeEntries.cost, - reportCurrency + reportCurrency, + reportCurrencyFormat, + reportCurrencySymbol, ) }}
diff --git a/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue b/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue index d7c381da..4e2b8547 100644 --- a/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue +++ b/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue @@ -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 { diff --git a/resources/js/packages/api/src/index.ts b/resources/js/packages/api/src/index.ts index f2366970..0ef2de92 100644 --- a/resources/js/packages/api/src/index.ts +++ b/resources/js/packages/api/src/index.ts @@ -167,7 +167,10 @@ export type ApiTokenIndexResponse = ZodiosResponseByAlias< 'getApiTokens' >; -export type CreateApiTokenBody = ZodiosBodyByAlias; +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 }; diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index ebdcec47..ff5cd647 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -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', diff --git a/resources/js/packages/ui/src/Input/BillableRateInput.vue b/resources/js/packages/ui/src/Input/BillableRateInput.vue index bfc246a0..4eb49d12 100644 --- a/resources/js/packages/ui/src/Input/BillableRateInput.vue +++ b/resources/js/packages/ui/src/Input/BillableRateInput.vue @@ -1,11 +1,9 @@