diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index ecc230173..b9359d691 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -1,5 +1,5 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; -import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; +import type { BillingPlan, ChangePlanRequest, ChangePlanResult, StringValueFromBody } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; @@ -26,6 +26,7 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me export const queryKeys = { adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const, changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const, + data: (id: string | undefined) => [...queryKeys.type, id, 'data'] as const, deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, icon: (id: string | undefined) => [...queryKeys.id(id, undefined), 'icon'] as const, id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), @@ -54,6 +55,16 @@ export interface ChangePlanMutationRequest { }; } +export interface DeleteOrganizationDataParams { + key: string; +} + +export interface DeleteOrganizationDataRequest { + route: { + id: string | undefined; + }; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -142,6 +153,17 @@ export interface PatchOrganizationRequest { }; } +export interface PostOrganizationDataParams { + key: string; + value: string; +} + +export interface PostOrganizationDataRequest { + route: { + id: string | undefined; + }; +} + export interface PostSetBonusOrganizationParams { bonusEvents: number; expires?: Date; @@ -225,6 +247,35 @@ export function deleteOrganization(request: DeleteOrganizationRequest) { })); } +export function deleteOrganizationData(request: DeleteOrganizationDataRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async ({ key }: DeleteOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${request.route.id}/data/${encodeURIComponent(key)}`); + return response.ok; + }, + mutationKey: queryKeys.data(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: (_, { key }) => { + updateOrganizationQueryData(queryClient, request.route.id, (organization) => { + if (!organization.data) { + return organization; + } + + const data = { ...organization.data }; + delete data[key]; + + return { ...organization, data }; + }); + } + })); +} + export function deleteOrganizationIcon(request: OrganizationIconRequest) { const queryClient = useQueryClient(); @@ -462,6 +513,32 @@ export function postOrganization() { })); } +export function postOrganizationData(request: PostOrganizationDataRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async ({ key, value }: PostOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.post(`organizations/${request.route.id}/data/${encodeURIComponent(key)}`, { value }); + return response.ok; + }, + mutationKey: queryKeys.data(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: (_, { key, value }) => { + updateOrganizationQueryData(queryClient, request.route.id, (organization) => ({ + ...organization, + data: { + ...(organization.data ?? {}), + [key]: value + } + })); + } + })); +} + export function postSetBonusOrganization() { const queryClient = useQueryClient(); @@ -594,3 +671,22 @@ function updateOrganizationCache(queryClient: QueryClient, id: string | undefine }; }); } + +function updateOrganizationQueryData(queryClient: QueryClient, id: string | undefined, updater: (organization: ViewOrganization) => ViewOrganization) { + for (const mode of [undefined, 'stats'] as const) { + queryClient.setQueryData(queryKeys.id(id, mode), (organization) => (organization ? updater(organization) : organization)); + } + + queryClient.setQueriesData | undefined>({ queryKey: queryKeys.type }, (response) => { + if (!Array.isArray(response?.data) || !response.data.some((organization) => organization.id === id)) { + return response; + } + + return { + ...response, + data: response.data.map((organization) => { + return organization.id === id ? updater(organization) : organization; + }) + }; + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts new file mode 100644 index 000000000..dccdd3395 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { getOrganizationBillingInformation, normalizeOrganizationBillingInformationValue, organizationBillingInformationDataKeys } from './billing-information'; + +describe('getOrganizationBillingInformation', () => { + it('returns billing information from known organization data keys', () => { + // Arrange + const organization = { + data: { + [organizationBillingInformationDataKeys.address]: '123 Main Street', + [organizationBillingInformationDataKeys.name]: 'Acme, Inc.', + [organizationBillingInformationDataKeys.vatId]: 'DE123456789', + [organizationBillingInformationDataKeys.vatNumber]: '123456789' + } + }; + + // Act + const billingInformation = getOrganizationBillingInformation(organization); + + // Assert + expect(billingInformation).toEqual({ + address: '123 Main Street', + name: 'Acme, Inc.', + vatId: 'DE123456789', + vatNumber: '123456789' + }); + }); + + it('defaults missing or non-string billing information values to empty strings', () => { + // Arrange + const organization = { + data: { + [organizationBillingInformationDataKeys.address]: ['invalid'], + [organizationBillingInformationDataKeys.name]: null, + [organizationBillingInformationDataKeys.vatId]: undefined, + [organizationBillingInformationDataKeys.vatNumber]: 42 + } + }; + + // Act + const billingInformation = getOrganizationBillingInformation(organization); + + // Assert + expect(billingInformation).toEqual({ + address: '', + name: '', + vatId: '', + vatNumber: '' + }); + }); +}); + +describe('normalizeOrganizationBillingInformationValue', () => { + it('trims non-empty values and removes blank values', () => { + // Arrange + const value = ' DE123456789 '; + const blankValue = ' '; + + // Act + const normalizedValue = normalizeOrganizationBillingInformationValue(value); + const normalizedBlankValue = normalizeOrganizationBillingInformationValue(blankValue); + + // Assert + expect(normalizedValue).toBe('DE123456789'); + expect(normalizedBlankValue).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts new file mode 100644 index 000000000..0be8fee37 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts @@ -0,0 +1,35 @@ +import type { ViewOrganization } from './models'; + +export const organizationBillingInformationDataKeys = { + address: 'billing_address', + name: 'billing_name', + vatId: 'billing_vat_id', + vatNumber: 'billing_vat_number' +} as const; + +export interface OrganizationBillingInformation { + address: string; + name: string; + vatId: string; + vatNumber: string; +} + +export function getOrganizationBillingInformation(organization?: null | Pick): OrganizationBillingInformation { + const data = organization?.data; + + return { + address: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.address]), + name: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.name]), + vatId: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatId]), + vatNumber: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatNumber]) + }; +} + +export function normalizeOrganizationBillingInformationValue(value: string): null | string { + const trimmedValue = value.trim(); + return trimmedValue || null; +} + +function getOrganizationBillingInformationValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts index c380dca5e..0ac569108 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts @@ -4,6 +4,14 @@ import { SuspensionCode } from './models'; export { type NewOrganizationFormData, NewOrganizationSchema } from '$generated/schemas'; +export const OrganizationBillingInformationSchema = object({ + address: string(), + name: string(), + vatId: string(), + vatNumber: string() +}); +export type OrganizationBillingInformationFormData = Infer; + export const SetBonusOrganizationSchema = object({ bonusEvents: number().int('Bonus events must be a whole number'), expires: date().optional() diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index 0805c8099..f08d45921 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -376,19 +376,6 @@ export function resolveConfiguredTableOptions( return baseOptions; } -export function withClientSortedRowModel(options: TableOptions): TableOptions { - const features = tableFeatures({ - ...options.features, - sortedRowModel: createSortedRowModel(), - sortFns - }); - - return { - ...options, - features - }; -} - export function resolvePageCount( strategy: PaginationStrategy, meta: QueryMeta | undefined, @@ -438,6 +425,19 @@ export function resolvePaginationChange(previousPageInfo: PaginationState, curre }; } +export function withClientSortedRowModel(options: TableOptions): TableOptions { + const features = tableFeatures({ + ...options.features, + sortedRowModel: createSortedRowModel(), + sortFns + }); + + return { + ...options, + features + }; +} + function createPersistedTableState(key: string, initialValue: T): [() => T, (updater: Updater) => void] { const persistedValue = new PersistedState(key, initialValue); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte index da1aa1f9f..fecd1c2fd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte @@ -1,26 +1,48 @@ -
+
+ Billing information and invoices + {#if organizationQuery.isLoading} -
+
{:else if organizationQuery.error} {:else} -
+
+
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + + + {#snippet children(field)} + + Billing name + { + field.handleChange(e.currentTarget.value); + debouncedFormSubmit(organizationId); + }} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#snippet children(field)} + + VAT ID + { + field.handleChange(e.currentTarget.value); + debouncedFormSubmit(organizationId); + }} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#snippet children(field)} + + Billing address +