From 07237dece6fae3f993552c50230ff882849157bc Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 24 Sep 2025 14:57:52 +0300 Subject: [PATCH 01/21] feat(MED-97): clean up --- .../20250924145253_fix_upsert_and_rls.sql | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/supabase/migrations/20250924145253_fix_upsert_and_rls.sql b/supabase/migrations/20250924145253_fix_upsert_and_rls.sql index d6d7589..b03c9c0 100644 --- a/supabase/migrations/20250924145253_fix_upsert_and_rls.sql +++ b/supabase/migrations/20250924145253_fix_upsert_and_rls.sql @@ -52,49 +52,3 @@ $$; -- 2. Grant permissions to authenticated users grant select, insert, update, delete on table "medreport"."benefit_distribution_schedule" to authenticated; - --- 3. Grant execute permissions to all functions -grant execute on function medreport.get_account_balance(uuid) to authenticated; -grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to authenticated; -grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to authenticated; -grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to authenticated; -grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to authenticated; -grant execute on function medreport.trigger_benefit_distribution(uuid) to authenticated; -grant execute on function medreport.trigger_distribute_benefits() to authenticated; -grant execute on function medreport.process_periodic_benefit_distributions() to authenticated; - --- 4. Ensure trigger function has security definer -create or replace function medreport.trigger_distribute_benefits() -returns trigger -language plpgsql -security definer -as $$ -begin - -- Only distribute if benefit_amount is set and greater than 0 - if new.benefit_amount is not null and new.benefit_amount > 0 then - -- Distribute benefits to all company members immediately - perform medreport.distribute_health_benefits( - new.account_id, - new.benefit_amount, - coalesce(new.benefit_occurance, 'yearly') - ); - - -- Create or update the distribution schedule for future distributions - perform medreport.upsert_benefit_distribution_schedule( - new.account_id, - new.benefit_amount, - coalesce(new.benefit_occurance, 'yearly') - ); - else - -- If benefit_amount is 0 or null, deactivate the schedule - update medreport.benefit_distribution_schedule - set is_active = false, updated_at = now() - where company_id = new.account_id; - end if; - - return new; -end; -$$; - --- 5. Grant execute permission to the updated trigger function -grant execute on function medreport.trigger_distribute_benefits() to authenticated, service_role; From 6c3ae1eda6823e4a87ee579e0a33189633be4e07 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 15:30:07 +0300 Subject: [PATCH 02/21] MED-102: clean order page for tto orders --- app/home/(user)/(dashboard)/order/page.tsx | 4 +- .../(user)/_components/booking/time-slots.tsx | 8 +-- .../_components/order/order-details.tsx | 2 +- .../(user)/_components/orders/order-block.tsx | 15 ++++-- .../_components/orders/order-items-table.tsx | 14 ++--- app/home/(user)/_lib/server/actions.ts | 8 +-- .../medipostPrivateMessage.service.ts | 2 +- lib/services/order.service.ts | 7 +-- lib/services/reservation.service.ts | 53 ++++++++++++------- lib/types/analysis-order.ts | 3 -- lib/types/order.ts | 12 +++++ public/locales/et/orders.json | 2 +- 12 files changed, 82 insertions(+), 48 deletions(-) delete mode 100644 lib/types/analysis-order.ts create mode 100644 lib/types/order.ts diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 2e9fe68..ee168ff 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -56,6 +56,7 @@ async function OrdersPage() { const analysisOrder = analysisOrders.find( ({ medusa_order_id }) => medusa_order_id === medusaOrder.id, ); + if (!medusaOrder) { return null; } @@ -81,6 +82,7 @@ async function OrdersPage() { ); })} - {analysisOrders.length === 0 && ( + {analysisOrders.length === 0 && ttoOrders.length === 0 && (
diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index 220a79f..4e28adc 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -167,15 +167,15 @@ const TimeSlots = ({ return toast.error(t('booking:serviceNotFound')); } - const bookTimePromise = updateReservationTime( + const bookTimePromise = updateReservationTime({ reservationId, - timeSlot.StartTime, - Number(syncedService.id), + newStartTime: timeSlot.StartTime, + newServiceId: Number(syncedService.id), timeSlot.UserID, timeSlot.SyncUserID, booking.selectedLocationId ? booking.selectedLocationId : null, cartId, - ); + }); toast.promise(() => bookTimePromise, { success: , diff --git a/app/home/(user)/_components/order/order-details.tsx b/app/home/(user)/_components/order/order-details.tsx index 1cda501..8dcff3f 100644 --- a/app/home/(user)/_components/order/order-details.tsx +++ b/app/home/(user)/_components/order/order-details.tsx @@ -2,7 +2,7 @@ import { formatDate } from 'date-fns'; import { Trans } from '@kit/ui/trans'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { AnalysisOrder } from '~/lib/types/order'; export default function OrderDetails({ order }: { order: AnalysisOrder }) { return ( diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index 4071b0f..ef6ab6d 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -5,18 +5,20 @@ import { Eye } from 'lucide-react'; import { Trans } from '@kit/ui/makerkit/trans'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { AnalysisOrder } from '~/lib/types/order'; import OrderItemsTable from './order-items-table'; export default function OrderBlock({ analysisOrder, + medusaOrderStatus, itemsAnalysisPackage, itemsTtoService, itemsOther, medusaOrderId, }: { analysisOrder?: AnalysisOrder; + medusaOrderStatus: string; itemsAnalysisPackage: StoreOrderLineItem[]; itemsTtoService: StoreOrderLineItem[]; itemsOther: StoreOrderLineItem[]; @@ -50,7 +52,11 @@ export default function OrderBlock({ )} {itemsTtoService && ( @@ -58,12 +64,15 @@ export default function OrderBlock({ items={itemsTtoService} title="orders:table.ttoService" type="ttoService" + order={{ status: medusaOrderStatus.toUpperCase() }} /> )} diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index 39ec1de..3495cea 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -17,7 +17,7 @@ import { } from '@kit/ui/table'; import { Trans } from '@kit/ui/trans'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { Order } from '~/lib/types/order'; import { logAnalysisResultsNavigateAction } from './actions'; @@ -26,12 +26,12 @@ export type OrderItemType = 'analysisOrder' | 'ttoService'; export default function OrderItemsTable({ items, title, - analysisOrder, + order, type = 'analysisOrder', }: { items: StoreOrderLineItem[]; title: string; - analysisOrder?: AnalysisOrder; + order: Order; type?: OrderItemType; }) { const router = useRouter(); @@ -43,9 +43,9 @@ export default function OrderItemsTable({ const isAnalysisOrder = type === 'analysisOrder'; const openAnalysisResults = async () => { - if (analysisOrder) { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + if (isAnalysisOrder && order?.medusaOrderId && order?.id) { + await logAnalysisResultsNavigateAction(order.medusaOrderId); + router.push(`${pathsConfig.app.analysisResults}/${order.id}`); } }; @@ -84,7 +84,7 @@ export default function OrderItemsTable({ diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index 43d90b8..f3998ca 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -23,16 +23,16 @@ export async function createInitialReservationAction( }); if (addedItem) { - const reservation = await createInitialReservation( + const reservation = await createInitialReservation({ serviceId, clinicId, appointmentUserId, - syncUserId, + syncUserID: syncUserId, startTime, - addedItem.id, + medusaLineItemId: addedItem.id, locationId, comments, - ); + }); await updateLineItem({ lineId: addedItem.id, diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts index 055acd6..997a12f 100644 --- a/lib/services/medipost/medipostPrivateMessage.service.ts +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -16,8 +16,8 @@ import axios from 'axios'; import { toArray } from '@kit/shared/utils'; import { Tables } from '@kit/supabase/database'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element'; +import type { AnalysisOrder } from '~/lib/types/order'; import { getAccountAdmin } from '../account.service'; import { getAnalyses } from '../analyses.service'; diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 12368be..c554ae9 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -4,7 +4,7 @@ import type { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import type { AnalysisOrder } from '../types/analysis-order'; +import type { AnalysisOrder, TTOOrder } from '../types/order'; export async function createAnalysisOrder({ medusaOrder, @@ -176,10 +176,7 @@ export async function getTtoOrders({ orderStatus, lineItemIds, }: { - orderStatus?: Tables< - { schema: 'medreport' }, - 'connected_online_reservation' - >['status']; + orderStatus?: TTOOrder['status']; lineItemIds?: string[]; } = {}) { const client = getSupabaseServerClient(); diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 330a8b2..7abf1d1 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -150,16 +150,25 @@ export async function getCartReservations( return results; } -export async function createInitialReservation( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserID: number, - startTime: Date, - medusaLineItemId: string, - locationId?: number | null, +export async function createInitialReservation({ + serviceId, + clinicId, + appointmentUserId, + syncUserID, + startTime, + medusaLineItemId, + locationId, comments = '', -) { +}: { + serviceId: number; + clinicId: number; + appointmentUserId: number; + syncUserID: number; + startTime: Date; + medusaLineItemId: string; + locationId?: number | null; + comments?: string; +}) { const logger = await getLogger(); const supabase = getSupabaseServerClient(); @@ -255,15 +264,23 @@ export async function getOrderedTtoServices({ return orderedTtoServices; } -export async function updateReservationTime( - reservationId: number, - newStartTime: Date, - newServiceId: number, - newAppointmentUserId: number, - newSyncUserId: number, - newLocationId: number | null, // TODO stop allowing null when Connected starts returning the correct ids instead of -1 - cartId: string, -) { +export async function updateReservationTime({ + reservationId, + newStartTime, + newServiceId, + newAppointmentUserId, + newSyncUserId, + newLocationId, // TODO stop allowing null when Connected starts returning the correct ids instead of -1 + cartId, +}: { + reservationId: number; + newStartTime: Date; + newServiceId: number; + newAppointmentUserId: number; + newSyncUserId: number; + newLocationId: number | null; + cartId: string; +}) { const logger = await getLogger(); const supabase = getSupabaseServerClient(); diff --git a/lib/types/analysis-order.ts b/lib/types/analysis-order.ts deleted file mode 100644 index a7ce721..0000000 --- a/lib/types/analysis-order.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Tables } from '@kit/supabase/database'; - -export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; diff --git a/lib/types/order.ts b/lib/types/order.ts new file mode 100644 index 0000000..92ce3f7 --- /dev/null +++ b/lib/types/order.ts @@ -0,0 +1,12 @@ +import type { Tables } from '@kit/supabase/database'; + +export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; +export type TTOOrder = Tables< + { schema: 'medreport' }, + 'connected_online_reservation' +>; +export type Order = { + medusaOrderId?: string; + id?: number; + status?: string; +}; diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json index 036c076..a57ff1c 100644 --- a/public/locales/et/orders.json +++ b/public/locales/et/orders.json @@ -27,7 +27,7 @@ "CANCELLED": "Tühistatud" }, "ttoService": { - "PENDING": "Alustatud", + "PENDING": "Laekumise ootel", "CONFIRMED": "Kinnitatud", "REJECTED": "Tagasi lükatud", "CANCELLED": "Tühistatud" From fc63b9e7b798085387c41cb3ea2dba83f7a88ace Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 09:43:49 +0300 Subject: [PATCH 03/21] feat(MED-97): move order status updating to user-analyses feature pkg --- .../medipostPrivateMessage.service.ts | 26 ++++++++++------- packages/features/doctor/package.json | 1 + .../services/doctor-analysis.service.ts | 27 +++++++++--------- .../features/user-analyses/src/server/api.ts | 28 ++++++++++++++++++- .../src/types/analysis-orders.ts | 1 + pnpm-lock.yaml | 3 ++ 6 files changed, 61 insertions(+), 25 deletions(-) diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts index 055acd6..efe0e74 100644 --- a/lib/services/medipost/medipostPrivateMessage.service.ts +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -28,7 +28,7 @@ import { upsertAnalysisResponseElement, } from '../analysis-order.service'; import { logMedipostDispatch } from '../audit.service'; -import { getAnalysisOrder, updateAnalysisOrderStatus } from '../order.service'; +import { getAnalysisOrder } from '../order.service'; import { parseXML } from '../util/xml.service'; import { MedipostValidationError } from './MedipostValidationError'; import { @@ -430,17 +430,19 @@ export async function readPrivateMessageResponse({ medipostExternalOrderId, }); if (status.isPartial) { - await updateAnalysisOrderStatus({ - medusaOrderId, - orderStatus: 'PARTIAL_ANALYSIS_RESPONSE', - }); + await createUserAnalysesApi(getSupabaseServerAdminClient()) + .updateAnalysisOrderStatus({ + medusaOrderId, + orderStatus: 'PARTIAL_ANALYSIS_RESPONSE', + }); hasAnalysisResponse = true; hasPartialAnalysisResponse = true; } else if (status.isCompleted) { - await updateAnalysisOrderStatus({ - medusaOrderId, - orderStatus: 'FULL_ANALYSIS_RESPONSE', - }); + await createUserAnalysesApi(getSupabaseServerAdminClient()) + .updateAnalysisOrderStatus({ + medusaOrderId, + orderStatus: 'FULL_ANALYSIS_RESPONSE', + }); if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { await deletePrivateMessage(privateMessageId); } @@ -622,5 +624,9 @@ export async function sendOrderToMedipost({ hasAnalysisResults: false, medusaOrderId, }); - await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); + await createUserAnalysesApi(getSupabaseServerAdminClient()) + .updateAnalysisOrderStatus({ + medusaOrderId, + orderStatus: 'PROCESSING', + }); } diff --git a/packages/features/doctor/package.json b/packages/features/doctor/package.json index df1c635..fdf8e74 100644 --- a/packages/features/doctor/package.json +++ b/packages/features/doctor/package.json @@ -13,6 +13,7 @@ "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", + "@kit/user-analyses": "workspace:*", "@makerkit/data-loader-supabase-core": "^0.0.10", "@makerkit/data-loader-supabase-nextjs": "^1.2.5", "@supabase/supabase-js": "2.49.4", diff --git a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts index c835c76..7033567 100644 --- a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts +++ b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts @@ -5,6 +5,7 @@ import { isBefore } from 'date-fns'; import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { getFullName } from '@kit/shared/utils'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; @@ -641,7 +642,14 @@ export async function submitFeedback( } if (status === 'COMPLETED') { - const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([ + const { data: analysisOrder } = await supabase + .schema('medreport') + .from('analysis_orders') + .select('medusa_order_id, id') + .eq('id', analysisOrderId) + .limit(1) + .throwOnError(); + const [{ data: recipient }] = await Promise.all([ supabase .schema('medreport') .from('accounts') @@ -649,19 +657,10 @@ export async function submitFeedback( .eq('is_personal_account', true) .eq('primary_owner_user_id', userId) .throwOnError(), - supabase - .schema('medreport') - .from('analysis_orders') - .select('medusa_order_id, id') - .eq('id', analysisOrderId) - .limit(1) - .throwOnError(), - supabase - .schema('medreport') - .from('analysis_orders') - .update({ status: 'COMPLETED' }) - .eq('id', analysisOrderId) - .throwOnError(), + createUserAnalysesApi(supabase).updateAnalysisOrderStatus({ + orderId: analysisOrderId, + orderStatus: 'COMPLETED', + }), ]); if (!recipient?.[0]?.email) { diff --git a/packages/features/user-analyses/src/server/api.ts b/packages/features/user-analyses/src/server/api.ts index 0532886..ec01587 100644 --- a/packages/features/user-analyses/src/server/api.ts +++ b/packages/features/user-analyses/src/server/api.ts @@ -4,7 +4,7 @@ import type { UuringuVastus } from '@kit/shared/types/medipost-analysis'; import { toArray } from '@kit/shared/utils'; import { Database } from '@kit/supabase/database'; -import type { AnalysisOrder } from '../types/analysis-orders'; +import type { AnalysisOrder, AnalysisOrderStatus } from '../types/analysis-orders'; import type { AnalysisResultDetailsElement, AnalysisResultDetailsMapped, @@ -450,6 +450,32 @@ class UserAnalysesApi { return data; } + + async updateAnalysisOrderStatus({ + orderId, + medusaOrderId, + orderStatus, + }: { + orderId?: number; + medusaOrderId?: string; + orderStatus: AnalysisOrderStatus; + }) { + const orderIdParam = orderId; + const medusaOrderIdParam = medusaOrderId; + + console.info(`Updating order id=${orderId} medusaOrderId=${medusaOrderId} status=${orderStatus}`); + if (!orderIdParam && !medusaOrderIdParam) { + throw new Error('Either orderId or medusaOrderId must be provided'); + } + await this.client + .schema('medreport') + .rpc('update_analysis_order_status', { + order_id: orderIdParam ?? -1, + status_param: orderStatus, + medusa_order_id_param: medusaOrderIdParam ?? '', + }) + .throwOnError(); + } } export function createUserAnalysesApi(client: SupabaseClient) { diff --git a/packages/features/user-analyses/src/types/analysis-orders.ts b/packages/features/user-analyses/src/types/analysis-orders.ts index 4ef4027..1eac2f0 100644 --- a/packages/features/user-analyses/src/types/analysis-orders.ts +++ b/packages/features/user-analyses/src/types/analysis-orders.ts @@ -1,3 +1,4 @@ import { Tables } from '@kit/supabase/database'; export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; +export type AnalysisOrderStatus = AnalysisOrder['status']; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ec48dd..2c396f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,6 +755,9 @@ importers: '@kit/ui': specifier: workspace:* version: link:../../ui + '@kit/user-analyses': + specifier: workspace:* + version: link:../user-analyses '@makerkit/data-loader-supabase-core': specifier: ^0.0.10 version: 0.0.10(@supabase/postgrest-js@1.19.4)(@supabase/supabase-js@2.49.4) From f091ed5b49b9eebd4493c6f4d60a9a149aed6279 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 09:45:18 +0300 Subject: [PATCH 04/21] feat(MED-97): update team account navigation links --- .../config/team-account-navigation.config.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/config/team-account-navigation.config.tsx b/packages/shared/src/config/team-account-navigation.config.tsx index 6f2c924..09bfdcd 100644 --- a/packages/shared/src/config/team-account-navigation.config.tsx +++ b/packages/shared/src/config/team-account-navigation.config.tsx @@ -1,4 +1,4 @@ -import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react'; +import { Euro, LayoutDashboard, Settings, Users } from 'lucide-react'; import { featureFlagsConfig, pathsConfig } from '@kit/shared/config'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; @@ -9,28 +9,28 @@ const getRoutes = (account: string) => [ { children: [ { - label: 'common:routes.dashboard', + label: 'common:routes.companyDashboard', path: pathsConfig.app.accountHome.replace('[account]', account), Icon: , end: true, }, { - label: 'common:routes.settings', - path: createPath(pathsConfig.app.accountSettings, account), - Icon: , - }, - { - label: 'common:routes.members', + label: 'common:routes.companyMembers', path: createPath(pathsConfig.app.accountMembers, account), Icon: , }, featureFlagsConfig.enableTeamAccountBilling ? { - label: 'common:routes.billing', - path: createPath(pathsConfig.app.accountBilling, account), - Icon: , - } + label: 'common:routes.billing', + path: createPath(pathsConfig.app.accountBilling, account), + Icon: , + } : undefined, + { + label: 'common:routes.companySettings', + path: createPath(pathsConfig.app.accountSettings, account), + Icon: , + }, ].filter(Boolean), }, ]; From 579ec7547ef144af28d2d07beb656cad487a59fd Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 09:45:35 +0300 Subject: [PATCH 05/21] feat(MED-97): fix client type --- packages/supabase/src/clients/server-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index c9b8d7e..4c20471 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -11,10 +11,10 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys'; * @name getSupabaseServerClient * @description Creates a Supabase client for use in the Server. */ -export function getSupabaseServerClient() { +export function getSupabaseServerClient() { const keys = getSupabaseClientKeys(); - return createServerClient(keys.url, keys.anonKey, { + return createServerClient(keys.url, keys.anonKey, { auth: { flowType: 'pkce', autoRefreshToken: true, From 56f84a003cb22438b45aabea0e891e88035ba081 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 09:47:28 +0300 Subject: [PATCH 06/21] feat(MED-97): move shared order placing logic to cart-actions --- .../cart/montonio-callback/actions.ts | 218 +------------ app/home/(user)/_lib/server/cart-actions.ts | 308 ++++++++++++++++++ 2 files changed, 312 insertions(+), 214 deletions(-) create mode 100644 app/home/(user)/_lib/server/cart-actions.ts diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index fd3c894..a23660c 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -2,100 +2,13 @@ import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; -import { placeOrder, retrieveCart } from '@lib/data/cart'; -import { listProductTypes } from '@lib/data/products'; -import type { StoreOrder } from '@medusajs/types'; +import { retrieveCart } from '@lib/data/cart'; import jwt from 'jsonwebtoken'; -import { z } from 'zod'; -import type { AccountWithParams } from '@kit/accounts/types/accounts'; -import { createNotificationsApi } from '@kit/notifications/api'; -import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import { handlePlaceOrder } from '../../../_lib/server/cart-actions'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; -import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; -import { - createAnalysisOrder, - getAnalysisOrder, -} from '~/lib/services/order.service'; - -const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; -const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; const MONTONIO_PAID_STATUS = 'PAID'; -const env = () => - z - .object({ - emailSender: z - .string({ - error: 'EMAIL_SENDER is required', - }) - .min(1), - siteUrl: z - .string({ - error: 'NEXT_PUBLIC_SITE_URL is required', - }) - .min(1), - isEnabledDispatchOnMontonioCallback: z.boolean({ - error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', - }), - }) - .parse({ - emailSender: process.env.EMAIL_SENDER, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, - isEnabledDispatchOnMontonioCallback: - process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', - }); - -const sendEmail = async ({ - account, - email, - analysisPackageName, - partnerLocationName, - language, -}: { - account: Pick; - email: string; - analysisPackageName: string; - partnerLocationName: string; - language: string; -}) => { - const client = getSupabaseServerAdminClient(); - try { - const { renderSynlabAnalysisPackageEmail } = await import( - '@kit/email-templates' - ); - const { getMailer } = await import('@kit/mailers'); - - const mailer = await getMailer(); - - const { html, subject } = await renderSynlabAnalysisPackageEmail({ - analysisPackageName, - personName: account.name, - partnerLocationName, - language, - }); - - await mailer - .sendEmail({ - from: env().emailSender, - to: email, - subject, - html, - }) - .catch((error) => { - throw new Error(`Failed to send email, message=${error}`); - }); - await createNotificationsApi(client).createNotification({ - account_id: account.id, - body: html, - }); - } catch (error) { - throw new Error(`Failed to send email, message=${error}`); - } -}; - async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -122,74 +35,6 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { return cart; } -async function getOrderResultParameters(medusaOrder: StoreOrder) { - const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, - ); - const analysisType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, - ); - - const analysisPackageOrderItem = medusaOrder.items?.find( - ({ product_type_id }) => product_type_id === analysisPackagesType?.id, - ); - const analysisItems = medusaOrder.items?.filter( - ({ product_type_id }) => product_type_id === analysisType?.id, - ); - - return { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - analysisPackageOrder: analysisPackageOrderItem - ? { - partnerLocationName: - (analysisPackageOrderItem?.metadata - ?.partner_location_name as string) ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - } - : null, - analysisItemsOrder: - Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', - })) - : null, - }; -} - -async function sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, -}: { - account: AccountWithParams; - email: string; - analysisPackageOrder: { - partnerLocationName: string; - analysisPackageName: string; - }; -}) { - const { language } = await createI18nServerInstance(); - const { analysisPackageName, partnerLocationName } = analysisPackageOrder; - try { - await sendEmail({ - account: { id: account.id, name: account.name }, - email, - analysisPackageName, - partnerLocationName, - language, - }); - console.info(`Successfully sent analysis package order email to ${email}`); - } catch (error) { - console.error( - `Failed to send analysis package order email to ${email}`, - error, - ); - } -} - export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { @@ -199,63 +44,8 @@ export async function processMontonioCallback(orderToken: string) { try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - - const medusaOrder = await placeOrder(cart.id, { - revalidateCacheTags: false, - }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ - medusaOrder, - }); - - try { - const existingAnalysisOrder = await getAnalysisOrder({ - medusaOrderId: medusaOrder.id, - }); - console.info( - `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, - ); - return { success: true, orderId: existingAnalysisOrder.id }; - } catch { - // ignored - } - - const orderId = await createAnalysisOrder({ - medusaOrder, - orderedAnalysisElements, - }); - const orderResult = await getOrderResultParameters(medusaOrder); - - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = - orderResult; - - if (email) { - if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, - }); - } else { - console.info(`Order has no analysis package, skipping email.`); - } - - if (analysisItemsOrder) { - // @TODO send email for separate analyses - console.warn( - `Order has analysis items, but no email template exists yet`, - ); - } else { - console.info(`Order has no analysis items, skipping email.`); - } - } else { - console.error('Missing email to send order result email', orderResult); - } - - if (env().isEnabledDispatchOnMontonioCallback) { - await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); - } - - return { success: true, orderId }; + const result = await handlePlaceOrder({ cart }); + return result; } catch (error) { console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); diff --git a/app/home/(user)/_lib/server/cart-actions.ts b/app/home/(user)/_lib/server/cart-actions.ts new file mode 100644 index 0000000..fb95d49 --- /dev/null +++ b/app/home/(user)/_lib/server/cart-actions.ts @@ -0,0 +1,308 @@ +'use server'; + +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; + +import type { StoreCart, StoreOrder } from "@medusajs/types"; + +import { initiateMultiPaymentSession, placeOrder } from "@lib/data/cart"; +import type { AccountBalanceSummary } from "~/lib/services/accountBalance.service"; +import { handleNavigateToPayment } from "~/lib/services/medusaCart.service"; +import { loadCurrentUserAccount } from "./load-user-account"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; +import { createAnalysisOrder, getAnalysisOrder } from "~/lib/services/order.service"; +import { listProductTypes } from "@lib/data"; +import { sendOrderToMedipost } from "~/lib/services/medipost/medipostPrivateMessage.service"; +import { AccountWithParams } from "@/packages/features/accounts/src/types/accounts"; +import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; +import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; +import { createNotificationsApi } from "@/packages/features/notifications/src/server/api"; + +const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; +const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; + +const env = () => + z + .object({ + emailSender: z + .string({ + error: 'EMAIL_SENDER is required', + }) + .min(1), + siteUrl: z + .string({ + error: 'NEXT_PUBLIC_SITE_URL is required', + }) + .min(1), + isEnabledDispatchOnMontonioCallback: z.boolean({ + error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', + }), + medusaBackendPublicUrl: z.string({ + error: 'MEDUSA_BACKEND_PUBLIC_URL is required', + }).min(1), + companyBenefitsPaymentSecretKey: z.string({ + error: 'COMPANY_BENEFITS_PAYMENT_SECRET_KEY is required', + }).min(1), + }) + .parse({ + emailSender: process.env.EMAIL_SENDER, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + isEnabledDispatchOnMontonioCallback: + process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', + medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, + companyBenefitsPaymentSecretKey: process.env.COMPANY_BENEFITS_PAYMENT_SECRET_KEY!, + }); + +export const initiatePayment = async ({ + accountId, + balanceSummary, + cart, + language, +}: { + accountId: string; + balanceSummary: AccountBalanceSummary; + cart: StoreCart; + language: string; +}) => { + try { + const { + montonioPaymentSessionId, + companyBenefitsPaymentSessionId, + totalByMontonio, + totalByBenefits, + isFullyPaidByBenefits, + } = await initiateMultiPaymentSession(cart, balanceSummary.totalBalance); + + if (!isFullyPaidByBenefits) { + if (!montonioPaymentSessionId) { + throw new Error('Montonio payment session ID is missing'); + } + const url = await handleNavigateToPayment({ + language, + paymentSessionId: montonioPaymentSessionId, + amount: totalByMontonio, + currencyCode: cart.currency_code, + cartId: cart.id, + }); + return { url }; + } else { + // place order if all paid already + const { orderId } = await handlePlaceOrder({ cart }); + + const companyBenefitsOrderToken = jwt.sign({ + accountId, + companyBenefitsPaymentSessionId, + orderId, + totalByBenefits, + }, env().companyBenefitsPaymentSecretKey, { + algorithm: 'HS256', + }); + const webhookResponse = await fetch(`${env().medusaBackendPublicUrl}/hooks/payment/company-benefits_company-benefits`, { + method: 'POST', + body: JSON.stringify({ + orderToken: companyBenefitsOrderToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!webhookResponse.ok) { + throw new Error('Failed to send company benefits webhook'); + } + return { isFullyPaidByBenefits, orderId }; + } + } catch (error) { + console.error('Error initiating payment', error); + } + + return { url: null } +} + +export async function handlePlaceOrder({ + cart, +}: { + cart: StoreCart; +}) { + const { account } = await loadCurrentUserAccount(); + if (!account) { + throw new Error('Account not found in context'); + } + + try { + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); + + try { + const existingAnalysisOrder = await getAnalysisOrder({ + medusaOrderId: medusaOrder.id, + }); + console.info( + `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, + ); + return { success: true, orderId: existingAnalysisOrder.id }; + } catch { + // ignored + } + + const orderId = await createAnalysisOrder({ + medusaOrder, + orderedAnalysisElements, + }); + const orderResult = await getOrderResultParameters(medusaOrder); + + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; + + if (email) { + if (analysisPackageOrder) { + await sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, + }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } + + if (analysisItemsOrder) { + // @TODO send email for separate analyses + console.warn( + `Order has analysis items, but no email template exists yet`, + ); + } else { + console.info(`Order has no analysis items, skipping email.`); + } + } else { + console.error('Missing email to send order result email', orderResult); + } + + if (env().isEnabledDispatchOnMontonioCallback) { + await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); + } + + return { success: true, orderId }; + } catch (error) { + console.error('Failed to place order', error); + throw new Error(`Failed to place order, message=${error}`); + } +} + +async function sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, +}: { + account: AccountWithParams; + email: string; + analysisPackageOrder: { + partnerLocationName: string; + analysisPackageName: string; + }; +}) { + const { language } = await createI18nServerInstance(); + const { analysisPackageName, partnerLocationName } = analysisPackageOrder; + try { + await sendEmail({ + account: { id: account.id, name: account.name }, + email, + analysisPackageName, + partnerLocationName, + language, + }); + console.info(`Successfully sent analysis package order email to ${email}`); + } catch (error) { + console.error( + `Failed to send analysis package order email to ${email}`, + error, + ); + } +} + +async function getOrderResultParameters(medusaOrder: StoreOrder) { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, + ); + const analysisType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, + ); + + const analysisPackageOrderItem = medusaOrder.items?.find( + ({ product_type_id }) => product_type_id === analysisPackagesType?.id, + ); + const analysisItems = medusaOrder.items?.filter( + ({ product_type_id }) => product_type_id === analysisType?.id, + ); + + return { + medusaOrderId: medusaOrder.id, + email: medusaOrder.email, + analysisPackageOrder: analysisPackageOrderItem + ? { + partnerLocationName: + (analysisPackageOrderItem?.metadata + ?.partner_location_name as string) ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: + Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', + })) + : null, + }; +} + +const sendEmail = async ({ + account, + email, + analysisPackageName, + partnerLocationName, + language, +}: { + account: Pick; + email: string; + analysisPackageName: string; + partnerLocationName: string; + language: string; +}) => { + const client = getSupabaseServerAdminClient(); + try { + const { renderSynlabAnalysisPackageEmail } = await import( + '@kit/email-templates' + ); + const { getMailer } = await import('@kit/mailers'); + + const mailer = await getMailer(); + + const { html, subject } = await renderSynlabAnalysisPackageEmail({ + analysisPackageName, + personName: account.name, + partnerLocationName, + language, + }); + + await mailer + .sendEmail({ + from: env().emailSender, + to: email, + subject, + html, + }) + .catch((error) => { + throw new Error(`Failed to send email, message=${error}`); + }); + await createNotificationsApi(client).createNotification({ + account_id: account.id, + body: html, + }); + } catch (error) { + throw new Error(`Failed to send email, message=${error}`); + } +}; From db38e602aad8805a17e53c161ad642b17e53caf2 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 13:24:09 +0300 Subject: [PATCH 07/21] feat(MED-97): update cart flow for using benefits --- app/home/(user)/(dashboard)/cart/page.tsx | 26 +++- app/home/(user)/_components/cart/index.tsx | 71 +++++++--- .../(user)/_components/order/cart-totals.tsx | 52 +++++++- .../_components/order/order-details.tsx | 2 +- .../(user)/_lib/server/balance-actions.ts | 13 ++ app/home/(user)/_lib/server/cart-actions.ts | 2 +- lib/services/medusaCart.service.ts | 21 +-- lib/services/order.service.ts | 42 ------ lib/types/account-balance-entry.ts | 3 + packages/features/accounts/package.json | 1 + .../services/account-balance.service.ts | 125 ++++++++++++++++++ .../src/types/account-balance-entry.ts | 3 + .../medusa-storefront/src/lib/data/cart.ts | 31 +++++ packages/supabase/src/database.types.ts | 49 +++++++ .../20250926040043_update_consume_balance.sql | 59 +++++++++ 15 files changed, 419 insertions(+), 81 deletions(-) create mode 100644 app/home/(user)/_lib/server/balance-actions.ts create mode 100644 lib/types/account-balance-entry.ts create mode 100644 packages/features/accounts/src/server/services/account-balance.service.ts create mode 100644 packages/features/accounts/src/types/account-balance-entry.ts create mode 100644 supabase/migrations/20250926040043_update_consume_balance.sql diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 41dca03..a514cad 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -1,5 +1,3 @@ -import { notFound } from 'next/navigation'; - import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; import { retrieveCart } from '@lib/data/cart'; @@ -11,6 +9,8 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; +import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; +import { AccountBalanceService } from '~/lib/services/accountBalance.service'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -21,12 +21,22 @@ export async function generateMetadata() { } async function CartPage() { - const cart = await retrieveCart().catch((error) => { - console.error('Failed to retrieve cart', error); - return notFound(); - }); + const [ + cart, + { productTypes }, + { account }, + ] = await Promise.all([ + retrieveCart(), + listProductTypes(), + loadCurrentUserAccount(), + ]); + + if (!account) { + return null; + } + + const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id); - const { productTypes } = await listProductTypes(); const analysisPackagesType = productTypes.find( ({ metadata }) => metadata?.handle === 'analysis-packages', ); @@ -63,9 +73,11 @@ async function CartPage() { {isTimerShown && } ); diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index 7887040..328dbdf 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -2,9 +2,7 @@ import { useState } from 'react'; -import { handleNavigateToPayment } from '@/lib/services/medusaCart.service'; import { formatCurrency } from '@/packages/shared/src/utils'; -import { initiatePaymentSession } from '@lib/data/cart'; import { StoreCart, StoreCartLineItem } from '@medusajs/types'; import { Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -16,27 +14,35 @@ import { Trans } from '@kit/ui/trans'; import AnalysisLocation from './analysis-location'; import CartItems from './cart-items'; import DiscountCode from './discount-code'; +import { initiatePayment } from '../../_lib/server/cart-actions'; +import { useRouter } from 'next/navigation'; +import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service'; const IS_DISCOUNT_SHOWN = true as boolean; export default function Cart({ + accountId, cart, synlabAnalyses, ttoServiceItems, + balanceSummary, }: { + accountId: string; cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; ttoServiceItems: StoreCartLineItem[]; + balanceSummary: AccountBalanceSummary | null; }) { const { i18n: { language }, } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); - + const router = useRouter(); const items = cart?.items ?? []; + const hasCartItems = cart && Array.isArray(items) && items.length > 0; - if (!cart || items.length === 0) { + if (!hasCartItems) { return (
@@ -56,24 +62,35 @@ export default function Cart({ ); } - async function initiatePayment() { + async function initiateSession() { setIsInitiatingSession(true); - const response = await initiatePaymentSession(cart!, { - provider_id: 'pp_montonio_montonio', - }); - if (response.payment_collection) { - const { payment_sessions } = response.payment_collection; - const paymentSessionId = payment_sessions![0]!.id; - const url = await handleNavigateToPayment({ language, paymentSessionId }); - window.location.href = url; - } else { + + try { + const { url, isFullyPaidByBenefits, orderId } = await initiatePayment({ + accountId, + balanceSummary: balanceSummary!, + cart: cart!, + language, + }); + if (url) { + window.location.href = url; + } else if (isFullyPaidByBenefits) { + if (typeof orderId !== 'number') { + throw new Error('Order ID is missing'); + } + router.push(`/home/order/${orderId}/confirmed`); + } + } catch (error) { + console.error('Failed to initiate payment', error); setIsInitiatingSession(false); } } - const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0; const isLocationsShown = synlabAnalyses.length > 0; + const companyBenefitsTotal = balanceSummary?.totalBalance ?? 0; + const montonioTotal = cart && companyBenefitsTotal > 0 ? cart.total - companyBenefitsTotal : cart.total; + return (
@@ -106,7 +123,7 @@ export default function Cart({

-
+

@@ -122,6 +139,24 @@ export default function Cart({

+ {companyBenefitsTotal > 0 && ( +
+
+

+ +

+
+
+

+ {formatCurrency({ + value: (companyBenefitsTotal > cart.total ? cart.total : companyBenefitsTotal), + currencyCode: cart.currency_code, + locale: language, + })} +

+
+
+ )}

@@ -131,7 +166,7 @@ export default function Cart({

{formatCurrency({ - value: cart.total, + value: montonioTotal < 0 ? 0 : montonioTotal, currencyCode: cart.currency_code, locale: language, })} @@ -175,7 +210,7 @@ export default function Cart({

From 1aeee0bc30960f8208fd32beb6768c975d2e3bc6 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 13:47:32 +0300 Subject: [PATCH 11/21] feat(MED-97): update benefit stats view in dashboards --- .../(user)/_components/dashboard-cards.tsx | 55 +++++- app/home/(user)/_components/dashboard.tsx | 8 +- .../team-account-benefit-statistics.tsx | 133 ++++---------- .../team-account-health-details.tsx | 6 +- .../_components/team-account-statistics.tsx | 70 +++----- ...-team-account-benefit-expenses-overview.ts | 75 ++++++++ .../load-team-account-benefit-statistics.ts | 96 ++++++++++ .../load-team-account-health-details.ts | 8 +- .../health-benefit-form-client.tsx | 97 ++++++++++ .../_components/health-benefit-form.tsx | 166 ++++++------------ .../_components/yearly-expenses-overview.tsx | 96 +++++----- app/home/[account]/billing/page.tsx | 10 +- app/home/[account]/members/page.tsx | 2 +- app/home/[account]/page.tsx | 7 +- lib/types/account-balance-entry.ts | 3 - lib/utils.ts | 7 +- .../features/accounts/src/types/accounts.ts | 32 ++-- packages/features/admin/package.json | 1 + .../src/components/admin-accounts-table.tsx | 4 +- .../lib/server/schema/admin-actions.schema.ts | 6 +- .../server/services/admin-accounts.service.ts | 3 +- .../webhooks/account-webhooks.service.ts | 4 +- pnpm-lock.yaml | 3 + 23 files changed, 518 insertions(+), 374 deletions(-) create mode 100644 app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts create mode 100644 app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts create mode 100644 app/home/[account]/billing/_components/health-benefit-form-client.tsx delete mode 100644 lib/types/account-balance-entry.ts diff --git a/app/home/(user)/_components/dashboard-cards.tsx b/app/home/(user)/_components/dashboard-cards.tsx index 686afe9..f8adcb0 100644 --- a/app/home/(user)/_components/dashboard-cards.tsx +++ b/app/home/(user)/_components/dashboard-cards.tsx @@ -3,12 +3,33 @@ import Link from 'next/link'; import { ChevronRight, HeartPulse } from 'lucide-react'; import { Button } from '@kit/ui/button'; -import { Card, CardDescription, CardFooter, CardHeader } from '@kit/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@kit/ui/card'; import { Trans } from '@kit/ui/trans'; +import { formatCurrency } from '@/packages/shared/src/utils'; +import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; +import { cn } from '@kit/ui/lib/utils'; +import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; +import { getAccountBalanceSummary } from '../_lib/server/balance-actions'; + +export default async function DashboardCards() { + const { language } = await createI18nServerInstance(); + + const { account } = await loadCurrentUserAccount(); + const balanceSummary = account ? await getAccountBalanceSummary(account.id) : null; -export default function DashboardCards() { return ( -
+
+ + + + + + + + + +
+ {formatCurrency({ + value: balanceSummary?.totalBalance || 0, + locale: language, + currencyCode: 'EUR', + })} +
+ + + +
+
); } + +function Figure(props: React.PropsWithChildren) { + return
{props.children}
; +} diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index 53e93ad..e0fbf7e 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -2,7 +2,6 @@ import Link from 'next/link'; -import { Database } from '@/packages/supabase/src/database.types'; import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons'; import { isNil } from 'lodash'; import { @@ -15,7 +14,7 @@ import { User, } from 'lucide-react'; -import type { AccountWithParams } from '@kit/accounts/types/accounts'; +import type { AccountWithParams, BmiThresholds } from '@kit/accounts/types/accounts'; import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; import { @@ -138,10 +137,7 @@ export default function Dashboard({ bmiThresholds, }: { account: AccountWithParams; - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[]; + bmiThresholds: Omit[]; }) { const height = account.accountParams?.height || 0; const weight = account.accountParams?.weight || 0; diff --git a/app/home/[account]/_components/team-account-benefit-statistics.tsx b/app/home/[account]/_components/team-account-benefit-statistics.tsx index dd4d983..035ff9d 100644 --- a/app/home/[account]/_components/team-account-benefit-statistics.tsx +++ b/app/home/[account]/_components/team-account-benefit-statistics.tsx @@ -1,23 +1,14 @@ import React from 'react'; -import { redirect } from 'next/navigation'; - import { formatCurrency } from '@/packages/shared/src/utils'; -import { Database } from '@/packages/supabase/src/database.types'; -import { PiggyBankIcon, Settings } from 'lucide-react'; +import { PiggyBankIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { createPath, pathsConfig } from '@kit/shared/config'; import { Card, CardTitle } from '@kit/ui/card'; import { cn } from '@kit/ui/lib/utils'; -import { Button } from '@kit/ui/shadcn/button'; import { Trans } from '@kit/ui/trans'; -interface TeamAccountBenefitStatisticsProps { - employeeCount: number; - accountSlug: string; - companyParams: Database['medreport']['Tables']['company_params']['Row']; -} +import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benefit-statistics'; const StatisticsCard = ({ children }: { children: React.ReactNode }) => { return {children}; @@ -46,10 +37,10 @@ const StatisticsValue = ({ children }: { children: React.ReactNode }) => { }; const TeamAccountBenefitStatistics = ({ - employeeCount, - accountSlug, - companyParams, -}: TeamAccountBenefitStatisticsProps) => { + accountBenefitStatistics, +}: { + accountBenefitStatistics: AccountBenefitStatistics; +}) => { const { i18n: { language }, } = useTranslation(); @@ -58,114 +49,64 @@ const TeamAccountBenefitStatistics = ({
-
-

- -

-

- -

- - - + + + + + {formatCurrency({ + value: accountBenefitStatistics.periodTotal, + locale: language, + currencyCode: 'EUR', + })} +
- + - 1800 € + + {formatCurrency({ + value: accountBenefitStatistics.orders.totalSum, + locale: language, + currencyCode: 'EUR', + })} + + - 200 € + {accountBenefitStatistics.orders.analysesSum} € - - - - - - - 200 € - - - - - - - - - 200 € - - - - - - - - - 200 € - - - + - 200 € + + {formatCurrency({ + value: accountBenefitStatistics.orders.analysisPackagesSum, + locale: language, + currencyCode: 'EUR', + })} + diff --git a/app/home/[account]/_components/team-account-health-details.tsx b/app/home/[account]/_components/team-account-health-details.tsx index 666a029..547a8a8 100644 --- a/app/home/[account]/_components/team-account-health-details.tsx +++ b/app/home/[account]/_components/team-account-health-details.tsx @@ -5,6 +5,7 @@ import { Database } from '@/packages/supabase/src/database.types'; import { Card } from '@kit/ui/card'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import type { BmiThresholds } from '@kit/accounts/types/accounts'; import { getAccountHealthDetailsFields } from '../_lib/server/load-team-account-health-details'; import { TeamAccountStatisticsProps } from './team-account-statistics'; @@ -15,10 +16,7 @@ const TeamAccountHealthDetails = ({ members, }: { memberParams: TeamAccountStatisticsProps['memberParams']; - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[]; + bmiThresholds: Omit[]; members: Database['medreport']['Functions']['get_account_members']['Returns']; }) => { const accountHealthDetailsFields = getAccountHealthDetailsFields( diff --git a/app/home/[account]/_components/team-account-statistics.tsx b/app/home/[account]/_components/team-account-statistics.tsx index fe8af56..bae53a4 100644 --- a/app/home/[account]/_components/team-account-statistics.tsx +++ b/app/home/[account]/_components/team-account-statistics.tsx @@ -14,28 +14,19 @@ import { createPath, pathsConfig } from '@kit/shared/config'; import { Card } from '@kit/ui/card'; import { Trans } from '@kit/ui/makerkit/trans'; import { Button } from '@kit/ui/shadcn/button'; -import { Calendar, DateRange } from '@kit/ui/shadcn/calendar'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@kit/ui/shadcn/popover'; +import { DateRange } from '@kit/ui/shadcn/calendar'; +import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benefit-statistics'; import TeamAccountBenefitStatistics from './team-account-benefit-statistics'; import TeamAccountHealthDetails from './team-account-health-details'; +import type { Account, AccountParams, BmiThresholds } from '@/packages/features/accounts/src/types/accounts'; export interface TeamAccountStatisticsProps { - teamAccount: Database['medreport']['Tables']['accounts']['Row']; - memberParams: Pick< - Database['medreport']['Tables']['account_params']['Row'], - 'weight' | 'height' - >[]; - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[]; + teamAccount: Account; + memberParams: Pick[]; + bmiThresholds: Omit[]; members: Database['medreport']['Functions']['get_account_members']['Returns']; - companyParams: Database['medreport']['Tables']['company_params']['Row']; + accountBenefitStatistics: AccountBenefitStatistics; } export default function TeamAccountStatistics({ @@ -43,11 +34,12 @@ export default function TeamAccountStatistics({ memberParams, bmiThresholds, members, - companyParams, + accountBenefitStatistics, }: TeamAccountStatisticsProps) { + const currentDate = new Date(); const [date, setDate] = useState({ - from: new Date(), - to: new Date(), + from: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1), + to: new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0), }); const { i18n: { language }, @@ -66,28 +58,16 @@ export default function TeamAccountStatistics({ /> - - - - - - - - +
- +
@@ -148,7 +124,7 @@ export default function TeamAccountStatistics({ redirect( createPath( pathsConfig.app.accountBilling, - teamAccount.slug || '', + teamAccount.slug!, ), ) } diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts new file mode 100644 index 0000000..93548d7 --- /dev/null +++ b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts @@ -0,0 +1,75 @@ +import { getSupabaseServerClient } from "@/packages/supabase/src/clients/server-client"; +import { loadAccountBenefitStatistics, loadCompanyPersonalAccountsBalanceEntries } from "./load-team-account-benefit-statistics"; + +export interface TeamAccountBenefitExpensesOverview { + benefitAmount: number | null; + benefitOccurrence: 'yearly' | 'monthly' | 'quarterly' | null; + currentMonthUsageTotal: number; + managementFee: number; + managementFeeTotal: number; + total: number; +} + +const MANAGEMENT_FEE = 5.50; + +const MONTHS = 12; +const QUARTERS = 4; + +export async function loadTeamAccountBenefitExpensesOverview({ + companyId, + employeeCount, +}: { + companyId: string; + employeeCount: number; +}): Promise { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .schema('medreport') + .from('benefit_distribution_schedule') + .select('*') + .eq('company_id', companyId) + .eq('is_active', true) + .single(); + + let benefitAmount: TeamAccountBenefitExpensesOverview['benefitAmount'] = null; + let benefitOccurrence: TeamAccountBenefitExpensesOverview['benefitOccurrence'] = null; + if (error) { + console.warn('Failed to load team account benefit expenses overview'); + } else { + benefitAmount = data.benefit_amount as TeamAccountBenefitExpensesOverview['benefitAmount']; + benefitOccurrence = data.benefit_occurrence as TeamAccountBenefitExpensesOverview['benefitOccurrence']; + } + + const { purchaseEntriesTotal } = await loadCompanyPersonalAccountsBalanceEntries({ accountId: companyId }); + + return { + benefitAmount, + benefitOccurrence, + currentMonthUsageTotal: purchaseEntriesTotal, + managementFee: MANAGEMENT_FEE, + managementFeeTotal: MANAGEMENT_FEE * employeeCount, + total: (() => { + if (typeof benefitAmount !== 'number') { + return 0; + } + + const currentDate = new Date(); + const createdAt = new Date(data.created_at); + const isCreatedThisYear = createdAt.getFullYear() === currentDate.getFullYear(); + if (benefitOccurrence === 'yearly') { + return benefitAmount * employeeCount; + } else if (benefitOccurrence === 'monthly') { + const monthsLeft = isCreatedThisYear + ? MONTHS - createdAt.getMonth() + : MONTHS; + return benefitAmount * employeeCount * monthsLeft; + } else if (benefitOccurrence === 'quarterly') { + const quartersLeft = isCreatedThisYear + ? QUARTERS - (createdAt.getMonth() / 3) + : QUARTERS; + return benefitAmount * employeeCount * quartersLeft; + } + return 0; + })(), + } +} diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts new file mode 100644 index 0000000..05f51f0 --- /dev/null +++ b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts @@ -0,0 +1,96 @@ +'use server'; + +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export interface AccountBenefitStatistics { + benefitDistributionSchedule: { + amount: number; + }; + companyAccountsCount: number; + periodTotal: number; + orders: { + totalSum: number; + + analysesCount: number; + analysesSum: number; + + analysisPackagesCount: number; + analysisPackagesSum: number; + } +} + +export const loadCompanyPersonalAccountsBalanceEntries = async ({ + accountId, +}: { + accountId: string; +}) => { + const supabase = getSupabaseServerAdminClient(); + + const { count, data: accountMemberships } = await supabase + .schema('medreport') + .from('accounts_memberships') + .select('user_id') + .eq('account_id', accountId) + .throwOnError(); + + const { data: accountBalanceEntries } = await supabase + .schema('medreport') + .from('account_balance_entries') + .select('*') + .eq('is_active', true) + .in('account_id', accountMemberships.map(({ user_id }) => user_id)) + .throwOnError(); + + const purchaseEntries = accountBalanceEntries.filter(({ entry_type }) => entry_type === 'purchase'); + const analysesEntries = purchaseEntries.filter(({ is_analysis_order }) => is_analysis_order); + const analysisPackagesEntries = purchaseEntries.filter(({ is_analysis_package_order }) => is_analysis_package_order); + + return { + accountBalanceEntries, + analysesEntries, + analysisPackagesEntries, + companyAccountsCount: count || 0, + purchaseEntries, + purchaseEntriesTotal: purchaseEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + }; +} + +export const loadAccountBenefitStatistics = async ( + accountId: string, +): Promise => { + const supabase = getSupabaseServerAdminClient(); + + const { + analysesEntries, + analysisPackagesEntries, + companyAccountsCount, + purchaseEntriesTotal, + } = await loadCompanyPersonalAccountsBalanceEntries({ accountId }); + + const { data: benefitDistributionSchedule } = await supabase + .schema('medreport') + .from('benefit_distribution_schedule') + .select('*') + .eq('company_id', accountId) + .eq('is_active', true) + .single() + .throwOnError(); + + const scheduleAmount = benefitDistributionSchedule?.benefit_amount || 0; + return { + companyAccountsCount, + benefitDistributionSchedule: { + amount: benefitDistributionSchedule?.benefit_amount || 0, + }, + periodTotal: scheduleAmount * companyAccountsCount, + orders: { + totalSum: purchaseEntriesTotal, + + analysesCount: analysesEntries.length, + analysesSum: analysesEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + + analysisPackagesCount: analysisPackagesEntries.length, + analysisPackagesSum: analysisPackagesEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + }, + }; +}; diff --git a/app/home/[account]/_lib/server/load-team-account-health-details.ts b/app/home/[account]/_lib/server/load-team-account-health-details.ts index 1705770..9568dda 100644 --- a/app/home/[account]/_lib/server/load-team-account-health-details.ts +++ b/app/home/[account]/_lib/server/load-team-account-health-details.ts @@ -11,6 +11,7 @@ import { } from '~/lib/utils'; import { TeamAccountStatisticsProps } from '../../_components/team-account-statistics'; +import type { BmiThresholds } from '@kit/accounts/types/accounts'; interface AccountHealthDetailsField { title: string; @@ -25,10 +26,7 @@ interface AccountHealthDetailsField { export const getAccountHealthDetailsFields = ( memberParams: TeamAccountStatisticsProps['memberParams'], - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[], + bmiThresholds: Omit[], members: Database['medreport']['Functions']['get_account_members']['Returns'], ): AccountHealthDetailsField[] => { const averageWeight = @@ -82,7 +80,7 @@ export const getAccountHealthDetailsFields = ( }, { title: 'teams:healthDetails.bmi', - value: averageBMI, + value: averageBMI!, Icon: TrendingUp, iconBg: getBmiBackgroundColor(bmiStatus), }, diff --git a/app/home/[account]/billing/_components/health-benefit-form-client.tsx b/app/home/[account]/billing/_components/health-benefit-form-client.tsx new file mode 100644 index 0000000..8812683 --- /dev/null +++ b/app/home/[account]/billing/_components/health-benefit-form-client.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState } from 'react'; + +import { UpdateHealthBenefitSchema } from '@/packages/billing/core/src/schema'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import { Button } from '@kit/ui/button'; +import { Form } from '@kit/ui/form'; +import { Spinner } from '@kit/ui/makerkit/spinner'; +import { toast } from '@kit/ui/shadcn/sonner'; +import { Trans } from '@kit/ui/trans'; + +import { cn } from '~/lib/utils'; + +import { updateHealthBenefit } from '../_lib/server/server-actions'; +import HealthBenefitFields from './health-benefit-fields'; +import { Account, CompanyParams } from '@/packages/features/accounts/src/types/accounts'; +import { useTranslation } from 'react-i18next'; + +const HealthBenefitFormClient = ({ + account, + companyParams, +}: { + account: Account; + companyParams: CompanyParams; +}) => { + const { t } = useTranslation('account'); + + const [currentCompanyParams, setCurrentCompanyParams] = + useState(companyParams); + const [isLoading, setIsLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(UpdateHealthBenefitSchema), + mode: 'onChange', + defaultValues: { + occurrence: currentCompanyParams.benefit_occurance || 'yearly', + amount: currentCompanyParams.benefit_amount || 0, + }, + }); + + const isDirty = form.formState.isDirty; + + const onSubmit = (data: { occurrence: string; amount: number }) => { + const promise = async () => { + setIsLoading(true); + try { + await updateHealthBenefit({ ...data, accountId: account.id }); + setCurrentCompanyParams((prev) => ({ + ...prev, + benefit_amount: data.amount, + benefit_occurance: data.occurrence, + })); + } finally { + form.reset(data); + setIsLoading(false); + } + }; + + toast.promise(promise, { + success: t('account:healthBenefitForm.updateSuccess'), + error: 'error', + }); + }; + + return ( +
+ + + + + + + ); +}; + +export default HealthBenefitFormClient; + + diff --git a/app/home/[account]/billing/_components/health-benefit-form.tsx b/app/home/[account]/billing/_components/health-benefit-form.tsx index a21a72e..d49ccd2 100644 --- a/app/home/[account]/billing/_components/health-benefit-form.tsx +++ b/app/home/[account]/billing/_components/health-benefit-form.tsx @@ -1,138 +1,70 @@ -'use client'; - -import { useState } from 'react'; - -import { UpdateHealthBenefitSchema } from '@/packages/billing/core/src/schema'; -import { Database } from '@/packages/supabase/src/database.types'; -import { zodResolver } from '@hookform/resolvers/zod'; import { PiggyBankIcon } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { Button } from '@kit/ui/button'; -import { Form } from '@kit/ui/form'; -import { Spinner } from '@kit/ui/makerkit/spinner'; import { Separator } from '@kit/ui/shadcn/separator'; -import { toast } from '@kit/ui/shadcn/sonner'; import { Trans } from '@kit/ui/trans'; -import { cn } from '~/lib/utils'; - -import { updateHealthBenefit } from '../_lib/server/server-actions'; -import HealthBenefitFields from './health-benefit-fields'; +import HealthBenefitFormClient from './health-benefit-form-client'; import YearlyExpensesOverview from './yearly-expenses-overview'; +import { TeamAccountBenefitExpensesOverview } from '../../_lib/server/load-team-account-benefit-expenses-overview'; +import { Account, CompanyParams } from '@/packages/features/accounts/src/types/accounts'; -const HealthBenefitForm = ({ +const HealthBenefitForm = async ({ account, companyParams, employeeCount, + expensesOverview, }: { - account: Database['medreport']['Tables']['accounts']['Row']; - companyParams: Database['medreport']['Tables']['company_params']['Row']; + account: Account; + companyParams: CompanyParams; employeeCount: number; + expensesOverview: TeamAccountBenefitExpensesOverview; }) => { - const [currentCompanyParams, setCurrentCompanyParams] = - useState( - companyParams, - ); - const [isLoading, setIsLoading] = useState(false); - const form = useForm({ - resolver: zodResolver(UpdateHealthBenefitSchema), - mode: 'onChange', - defaultValues: { - occurrence: currentCompanyParams.benefit_occurance || 'yearly', - amount: currentCompanyParams.benefit_amount || 0, - }, - }); - const isDirty = form.formState.isDirty; - - const onSubmit = (data: { occurrence: string; amount: number }) => { - const promise = async () => { - setIsLoading(true); - try { - await updateHealthBenefit({ ...data, accountId: account.id }); - setCurrentCompanyParams((prev) => ({ - ...prev, - benefit_amount: data.amount, - benefit_occurance: data.occurrence, - })); - } finally { - form.reset(data); - setIsLoading(false); - } - }; - - toast.promise(promise, { - success: 'Andmed uuendatud', - error: 'error', - }); - }; - return ( -
- -
-
-

- -

-

- -

-
- -
-
-
-
-
- -
-

- -

-

- {currentCompanyParams.benefit_amount || 0} € -

-
- - - -
- -
-
- -
- +
+
+

+ -

- +

+
+
+ +
+
+
+
+ +
+

+

+ + + +
+ +
- - + +
+ + +

+ +

+
+
+
); }; diff --git a/app/home/[account]/billing/_components/yearly-expenses-overview.tsx b/app/home/[account]/billing/_components/yearly-expenses-overview.tsx index 36a42be..afd3d21 100644 --- a/app/home/[account]/billing/_components/yearly-expenses-overview.tsx +++ b/app/home/[account]/billing/_components/yearly-expenses-overview.tsx @@ -1,50 +1,19 @@ -import { useMemo } from 'react'; - -import { Database } from '@/packages/supabase/src/database.types'; +'use client'; import { Trans } from '@kit/ui/makerkit/trans'; import { Separator } from '@kit/ui/separator'; +import { formatCurrency } from '@/packages/shared/src/utils'; +import { useTranslation } from 'react-i18next'; +import { TeamAccountBenefitExpensesOverview } from '../../_lib/server/load-team-account-benefit-expenses-overview'; const YearlyExpensesOverview = ({ employeeCount = 0, - companyParams, + expensesOverview, }: { employeeCount?: number; - companyParams: Database['medreport']['Tables']['company_params']['Row']; + expensesOverview: TeamAccountBenefitExpensesOverview; }) => { - const monthlyExpensePerEmployee = useMemo(() => { - if (!companyParams.benefit_amount) { - return '0.00'; - } - - switch (companyParams.benefit_occurance) { - case 'yearly': - return (companyParams.benefit_amount / 12).toFixed(2); - case 'quarterly': - return (companyParams.benefit_amount / 3).toFixed(2); - case 'monthly': - return companyParams.benefit_amount.toFixed(2); - default: - return '0.00'; - } - }, [companyParams]); - - const maxYearlyExpensePerEmployee = useMemo(() => { - if (!companyParams.benefit_amount) { - return '0.00'; - } - - switch (companyParams.benefit_occurance) { - case 'yearly': - return companyParams.benefit_amount.toFixed(2); - case 'quarterly': - return (companyParams.benefit_amount * 3).toFixed(2); - case 'monthly': - return (companyParams.benefit_amount * 12).toFixed(2); - default: - return '0.00'; - } - }, [companyParams]); + const { i18n: { language } } = useTranslation(); return (
@@ -53,41 +22,56 @@ const YearlyExpensesOverview = ({

- +

- {monthlyExpensePerEmployee} € + {employeeCount}
-

- -

- - {maxYearlyExpensePerEmployee} € - -
-

- {(Number(maxYearlyExpensePerEmployee) * employeeCount).toFixed(2)} € + {formatCurrency({ + value: expensesOverview.managementFeeTotal, + locale: language, + currencyCode: 'EUR', + })} + +
+
+

+ +

+ + {formatCurrency({ + value: expensesOverview.currentMonthUsageTotal, + locale: language, + currencyCode: 'EUR', + })}

- +

- {companyParams.benefit_amount - ? companyParams.benefit_amount * employeeCount - : 0}{' '} - € + {formatCurrency({ + value: expensesOverview.total, + locale: language, + currencyCode: 'EUR', + })}
diff --git a/app/home/[account]/billing/page.tsx b/app/home/[account]/billing/page.tsx index bc8288b..ed06890 100644 --- a/app/home/[account]/billing/page.tsx +++ b/app/home/[account]/billing/page.tsx @@ -7,6 +7,7 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; import HealthBenefitForm from './_components/health-benefit-form'; +import { loadTeamAccountBenefitExpensesOverview } from '../_lib/server/load-team-account-benefit-expenses-overview'; interface TeamAccountBillingPageProps { params: Promise<{ account: string }>; @@ -27,8 +28,14 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) { const api = createTeamAccountsApi(client); const account = await api.getTeamAccount(accountSlug); - const companyParams = await api.getTeamAccountParams(account.id); const { members } = await api.getMembers(accountSlug); + const [expensesOverview, companyParams] = await Promise.all([ + loadTeamAccountBenefitExpensesOverview({ + companyId: account.id, + employeeCount: members.length, + }), + api.getTeamAccountParams(account.id), + ]); return ( @@ -36,6 +43,7 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) { account={account} companyParams={companyParams} employeeCount={members.length} + expensesOverview={expensesOverview} /> ); diff --git a/app/home/[account]/members/page.tsx b/app/home/[account]/members/page.tsx index 324e6b0..fc11cea 100644 --- a/app/home/[account]/members/page.tsx +++ b/app/home/[account]/members/page.tsx @@ -54,7 +54,7 @@ async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { return ( <> } + title={} description={} /> diff --git a/app/home/[account]/page.tsx b/app/home/[account]/page.tsx index 7283798..5cff5d3 100644 --- a/app/home/[account]/page.tsx +++ b/app/home/[account]/page.tsx @@ -17,6 +17,7 @@ import { } from '~/lib/services/audit/pageView.service'; import { Dashboard } from './_components/dashboard'; +import { loadAccountBenefitStatistics } from './_lib/server/load-team-account-benefit-statistics'; interface TeamAccountHomePageProps { params: Promise<{ account: string }>; @@ -39,9 +40,7 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const teamAccount = use(teamAccountsApi.getTeamAccount(account)); const { memberParams, members } = use(teamAccountsApi.getMembers(account)); const bmiThresholds = use(userAnalysesApi.fetchBmiThresholds()); - const companyParams = use( - teamAccountsApi.getTeamAccountParams(teamAccount.id), - ); + const accountBenefitStatistics = use(loadAccountBenefitStatistics(teamAccount.id)); use( createPageViewLog({ @@ -57,7 +56,7 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { memberParams={memberParams} bmiThresholds={bmiThresholds} members={members} - companyParams={companyParams} + accountBenefitStatistics={accountBenefitStatistics} /> ); diff --git a/lib/types/account-balance-entry.ts b/lib/types/account-balance-entry.ts deleted file mode 100644 index 434e5e6..0000000 --- a/lib/types/account-balance-entry.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Database } from "@/packages/supabase/src/database.types"; - -export type AccountBalanceEntry = Database['medreport']['Tables']['account_balance_entries']['Row']; diff --git a/lib/utils.ts b/lib/utils.ts index d9d0f96..7d2f9ad 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,9 +1,9 @@ -import { Database } from '@/packages/supabase/src/database.types'; import { type ClassValue, clsx } from 'clsx'; import Isikukood, { Gender } from 'isikukood'; import { twMerge } from 'tailwind-merge'; import { BmiCategory } from './types/bmi'; +import type { BmiThresholds } from '@kit/accounts/types/accounts'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -45,10 +45,7 @@ export const bmiFromMetric = (kg: number, cm: number) => { }; export function getBmiStatus( - thresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[], + thresholds: Omit[], params: { age: number; height: number; weight: number }, ): BmiCategory | null { const age = params.age; diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index bc305c0..430c898 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -1,23 +1,25 @@ import { Database } from '@kit/supabase/database'; -export type ApplicationRole = - Database['medreport']['Tables']['accounts']['Row']['application_role']; +export type ApplicationRole = Account['application_role']; export enum ApplicationRoleEnum { User = 'user', Doctor = 'doctor', SuperAdmin = 'super_admin', } -export type AccountWithParams = - Database['medreport']['Tables']['accounts']['Row'] & { - accountParams: - | (Pick< - Database['medreport']['Tables']['account_params']['Row'], - 'weight' | 'height' - > & { - isSmoker: - | Database['medreport']['Tables']['account_params']['Row']['is_smoker'] - | null; - }) - | null; - }; +export type AccountParams = + Database['medreport']['Tables']['account_params']['Row']; + +export type Account = Database['medreport']['Tables']['accounts']['Row']; +export type AccountWithParams = Account & { + accountParams: + | (Pick & { + isSmoker: AccountParams['is_smoker'] | null; + }) + | null; +}; + +export type CompanyParams = + Database['medreport']['Tables']['company_params']['Row']; + +export type BmiThresholds = Database['medreport']['Tables']['bmi_thresholds']['Row']; diff --git a/packages/features/admin/package.json b/packages/features/admin/package.json index 72da143..d07673d 100644 --- a/packages/features/admin/package.json +++ b/packages/features/admin/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@hookform/resolvers": "^5.0.1", "@kit/next": "workspace:*", + "@kit/accounts": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx index ab7f0c1..d993ab7 100644 --- a/packages/features/admin/src/components/admin-accounts-table.tsx +++ b/packages/features/admin/src/components/admin-accounts-table.tsx @@ -11,7 +11,7 @@ import { EllipsisVertical } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { Database } from '@kit/supabase/database'; +import type { Account } from '@kit/accounts/types/accounts'; import { Button } from '@kit/ui/button'; import { Checkbox } from '@kit/ui/checkbox'; import { @@ -44,8 +44,6 @@ import { AdminDeleteUserDialog } from './admin-delete-user-dialog'; import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog'; import { AdminResetPasswordDialog } from './admin-reset-password-dialog'; -type Account = Database['medreport']['Tables']['accounts']['Row']; - const FiltersSchema = z.object({ type: z.enum(['all', 'team', 'personal']), query: z.string().optional(), diff --git a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts index 8edd356..aa5d86d 100644 --- a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts +++ b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { Database } from '@kit/supabase/database'; +import { ApplicationRole } from '@kit/accounts/types/accounts'; const ConfirmationSchema = z.object({ confirmation: z.custom((value) => value === 'CONFIRM'), @@ -19,9 +19,7 @@ export const DeleteAccountSchema = ConfirmationSchema.extend({ accountId: z.string().uuid(), }); -type ApplicationRoleType = - Database['medreport']['Tables']['accounts']['Row']['application_role']; export const UpdateAccountRoleSchema = z.object({ accountId: z.string().uuid(), - role: z.string() as z.ZodType, + role: z.string() as z.ZodType, }); diff --git a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts index c46bc03..6f26b58 100644 --- a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts +++ b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts @@ -3,6 +3,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; +import type { ApplicationRole } from '@kit/accounts/types/accounts'; export function createAdminAccountsService(client: SupabaseClient) { return new AdminAccountsService(client); @@ -25,7 +26,7 @@ class AdminAccountsService { async updateRole( accountId: string, - role: Database['medreport']['Tables']['accounts']['Row']['application_role'], + role: ApplicationRole, ) { const { error } = await this.adminClient .schema('medreport') diff --git a/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts b/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts index 9bdc5a3..d1d6aa4 100644 --- a/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts +++ b/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts @@ -1,9 +1,7 @@ import { z } from 'zod'; import { getLogger } from '@kit/shared/logger'; -import { Database } from '@kit/supabase/database'; - -type Account = Database['medreport']['Tables']['accounts']['Row']; +import type { Account } from '@kit/accounts/types/accounts'; export function createAccountWebhooksService() { return new AccountWebhooksService(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c396f6..b000d26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -647,6 +647,9 @@ importers: '@hookform/resolvers': specifier: ^5.0.1 version: 5.2.1(react-hook-form@7.62.0(react@19.1.0)) + '@kit/accounts': + specifier: workspace:* + version: link:../accounts '@kit/next': specifier: workspace:* version: link:../../next From 0aa16c457a23beee248846f71f3d593022b9764f Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 13:50:33 +0300 Subject: [PATCH 12/21] feat(MED-97): make sure new company employee accounts get benefits balance --- .../src/server/actions/team-invitations-server-actions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 4f11f93..69b8810 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -18,6 +18,7 @@ import { RenewInvitationSchema } from '../../schema/renew-invitation.schema'; import { UpdateInvitationSchema } from '../../schema/update-invitation.schema'; import { createAccountInvitationsService } from '../services/account-invitations.service'; import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service'; +import { AccountBalanceService } from '@kit/accounts/services/account-balance.service'; /** * @name createInvitationsAction @@ -171,6 +172,9 @@ export const acceptInvitationAction = enhanceAction( throw new Error('Failed to accept invitation'); } + // Make sure new account gets company benefits added to balance + await new AccountBalanceService().processPeriodicBenefitDistributions(); + // Increase the seats for the account await perSeatBillingService.increaseSeats(accountId); From 428cbd9477e4d30a1347eac9b45dcd701b2f149d Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 15:37:47 +0300 Subject: [PATCH 13/21] feat(MED-97): small fixes --- .../server/load-team-account-benefit-expenses-overview.ts | 4 ++-- .../_lib/server/load-team-account-benefit-statistics.ts | 3 +-- .../billing/_components/health-benefit-form-client.tsx | 5 ++++- .../[account]/billing/_components/health-benefit-form.tsx | 4 ++-- app/join/page.tsx | 2 +- .../accounts/src/server/services/account-balance.service.ts | 4 +++- .../src/server/actions/team-invitations-server-actions.ts | 3 ++- 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts index 93548d7..0f260ea 100644 --- a/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts +++ b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts @@ -1,5 +1,5 @@ import { getSupabaseServerClient } from "@/packages/supabase/src/clients/server-client"; -import { loadAccountBenefitStatistics, loadCompanyPersonalAccountsBalanceEntries } from "./load-team-account-benefit-statistics"; +import { loadCompanyPersonalAccountsBalanceEntries } from "./load-team-account-benefit-statistics"; export interface TeamAccountBenefitExpensesOverview { benefitAmount: number | null; @@ -65,7 +65,7 @@ export async function loadTeamAccountBenefitExpensesOverview({ return benefitAmount * employeeCount * monthsLeft; } else if (benefitOccurrence === 'quarterly') { const quartersLeft = isCreatedThisYear - ? QUARTERS - (createdAt.getMonth() / 3) + ? QUARTERS - Math.ceil(createdAt.getMonth() / 3) : QUARTERS; return benefitAmount * employeeCount * quartersLeft; } diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts index 05f51f0..4de4be5 100644 --- a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts +++ b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts @@ -73,8 +73,7 @@ export const loadAccountBenefitStatistics = async ( .select('*') .eq('company_id', accountId) .eq('is_active', true) - .single() - .throwOnError(); + .single(); const scheduleAmount = benefitDistributionSchedule?.benefit_amount || 0; return { diff --git a/app/home/[account]/billing/_components/health-benefit-form-client.tsx b/app/home/[account]/billing/_components/health-benefit-form-client.tsx index 8812683..6ca2ff2 100644 --- a/app/home/[account]/billing/_components/health-benefit-form-client.tsx +++ b/app/home/[account]/billing/_components/health-benefit-form-client.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { UpdateHealthBenefitSchema } from '@/packages/billing/core/src/schema'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -27,7 +28,8 @@ const HealthBenefitFormClient = ({ companyParams: CompanyParams; }) => { const { t } = useTranslation('account'); - + const router = useRouter(); + const [currentCompanyParams, setCurrentCompanyParams] = useState(companyParams); const [isLoading, setIsLoading] = useState(false); @@ -56,6 +58,7 @@ const HealthBenefitFormClient = ({ } finally { form.reset(data); setIsLoading(false); + router.refresh(); } }; diff --git a/app/home/[account]/billing/_components/health-benefit-form.tsx b/app/home/[account]/billing/_components/health-benefit-form.tsx index d49ccd2..4decbe2 100644 --- a/app/home/[account]/billing/_components/health-benefit-form.tsx +++ b/app/home/[account]/billing/_components/health-benefit-form.tsx @@ -32,8 +32,8 @@ const HealthBenefitForm = async ({
-
-
+
+
diff --git a/app/join/page.tsx b/app/join/page.tsx index 72b6e2f..cabd06b 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -116,7 +116,7 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) { return ( { - const { error } = await this.supabase.schema('medreport').rpc('process_periodic_benefit_distributions') + console.info('Processing periodic benefit distributions...'); + const { error } = await this.supabase.schema('medreport').rpc('process_periodic_benefit_distributions'); if (error) { console.error('Error processing periodic benefit distributions:', error); throw new Error('Failed to process periodic benefit distributions'); } + console.info('Periodic benefit distributions processed successfully'); } } diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 69b8810..ccb89e2 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -149,6 +149,7 @@ export const updateInvitationAction = enhanceAction( export const acceptInvitationAction = enhanceAction( async (data: FormData, user) => { const client = getSupabaseServerClient(); + const accountBalanceService = new AccountBalanceService(); const { inviteToken, nextPath } = AcceptInvitationSchema.parse( Object.fromEntries(data), @@ -173,7 +174,7 @@ export const acceptInvitationAction = enhanceAction( } // Make sure new account gets company benefits added to balance - await new AccountBalanceService().processPeriodicBenefitDistributions(); + await accountBalanceService.processPeriodicBenefitDistributions(); // Increase the seats for the account await perSeatBillingService.increaseSeats(accountId); From eb6ef2abe1c711516f2fc1c8e5259b70a8991296 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 15:39:33 +0300 Subject: [PATCH 14/21] feat(MED-97): show benefits amount for each member --- .../_lib/server/members-page.loader.ts | 26 ++++++++++++++- app/home/[account]/members/page.tsx | 3 +- .../members/account-members-table.tsx | 32 +++++++++++++++++-- public/locales/en/teams.json | 3 +- public/locales/et/teams.json | 3 +- public/locales/ru/teams.json | 3 +- 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/app/home/[account]/members/_lib/server/members-page.loader.ts b/app/home/[account]/members/_lib/server/members-page.loader.ts index 6025aba..db902bc 100644 --- a/app/home/[account]/members/_lib/server/members-page.loader.ts +++ b/app/home/[account]/members/_lib/server/members-page.loader.ts @@ -5,6 +5,7 @@ import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@/packages/supabase/src/database.types'; import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; +import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; /** * Load data for the members page @@ -15,11 +16,13 @@ export async function loadMembersPageData( client: SupabaseClient, slug: string, ) { + const workspace = await loadTeamWorkspace(slug); return Promise.all([ loadAccountMembers(client, slug), loadInvitations(client, slug), canAddMember, - loadTeamWorkspace(slug), + workspace, + loadAccountMembersBenefitsUsage(getSupabaseServerAdminClient(), workspace.account.id), ]); } @@ -60,6 +63,27 @@ async function loadAccountMembers( return data ?? []; } +export async function loadAccountMembersBenefitsUsage( + client: SupabaseClient, + accountId: string, +): Promise<{ + personal_account_id: string; + benefit_amount: number; +}[]> { + const { data, error } = await client + .schema('medreport') + .rpc('get_benefits_usages_for_company_members', { + p_account_id: accountId, + }); + + if (error) { + console.error('Failed to load account members benefits usage', error); + return []; + } + + return data ?? []; +} + /** * Load account invitations * @param client diff --git a/app/home/[account]/members/page.tsx b/app/home/[account]/members/page.tsx index fc11cea..0b4f4cd 100644 --- a/app/home/[account]/members/page.tsx +++ b/app/home/[account]/members/page.tsx @@ -42,7 +42,7 @@ async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { const client = getSupabaseServerClient(); const slug = (await params).account; - const [members, invitations, canAddMember, { user, account }] = + const [members, invitations, canAddMember, { user, account }, membersBenefitsUsage] = await loadMembersPageData(client, slug); const canManageRoles = account.permissions.includes('roles.manage'); @@ -96,6 +96,7 @@ async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { members={members} isPrimaryOwner={isPrimaryOwner} canManageRoles={canManageRoles} + membersBenefitsUsage={membersBenefitsUsage} /> diff --git a/packages/features/team-accounts/src/components/members/account-members-table.tsx b/packages/features/team-accounts/src/components/members/account-members-table.tsx index 0e461f9..5003f40 100644 --- a/packages/features/team-accounts/src/components/members/account-members-table.tsx +++ b/packages/features/team-accounts/src/components/members/account-members-table.tsx @@ -20,6 +20,7 @@ import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { ProfileAvatar } from '@kit/ui/profile-avatar'; import { Trans } from '@kit/ui/trans'; +import { formatCurrency } from '@kit/shared/utils'; import { RemoveMemberDialog } from './remove-member-dialog'; import { RoleBadge } from './role-badge'; @@ -42,6 +43,10 @@ type AccountMembersTableProps = { userRoleHierarchy: number; isPrimaryOwner: boolean; canManageRoles: boolean; + membersBenefitsUsage: { + personal_account_id: string; + benefit_amount: number; + }[]; }; export function AccountMembersTable({ @@ -51,6 +56,7 @@ export function AccountMembersTable({ isPrimaryOwner, userRoleHierarchy, canManageRoles, + membersBenefitsUsage, }: AccountMembersTableProps) { const [search, setSearch] = useState(''); const { t } = useTranslation('teams'); @@ -73,6 +79,7 @@ export function AccountMembersTable({ currentUserId, currentAccountId, currentRoleHierarchy: userRoleHierarchy, + membersBenefitsUsage, }); const filteredMembers = members @@ -122,9 +129,13 @@ function useGetColumns( currentUserId: string; currentAccountId: string; currentRoleHierarchy: number; + membersBenefitsUsage: { + personal_account_id: string; + benefit_amount: number; + }[]; }, ): ColumnDef[] { - const { t } = useTranslation('teams'); + const { t, i18n: { language } } = useTranslation('teams'); return useMemo( () => [ @@ -168,6 +179,23 @@ function useGetColumns( return row.original.personal_code ?? '-'; }, }, + { + header: t('distributedBenefitsAmount'), + cell: ({ row }) => { + const benefitAmount = params.membersBenefitsUsage.find( + (usage) => usage.personal_account_id === row.original.id + )?.benefit_amount; + if (typeof benefitAmount !== 'number') { + return '-'; + } + + return formatCurrency({ + currencyCode: 'EUR', + locale: language, + value: benefitAmount, + }); + }, + }, { header: t('roleLabel'), cell: ({ row }) => { @@ -175,7 +203,7 @@ function useGetColumns( const isPrimaryOwner = primary_owner_user_id === user_id; return ( - + diff --git a/public/locales/en/teams.json b/public/locales/en/teams.json index b015945..7bc7f6e 100644 --- a/public/locales/en/teams.json +++ b/public/locales/en/teams.json @@ -160,5 +160,6 @@ "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "reservedNameError": "This name is reserved. Please choose a different one.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.", - "personalCode": "Personal Code" + "personalCode": "Personal Code", + "distributedBenefitsAmount": "Assigned benefits" } diff --git a/public/locales/et/teams.json b/public/locales/et/teams.json index 6b85800..74376f5 100644 --- a/public/locales/et/teams.json +++ b/public/locales/et/teams.json @@ -193,5 +193,6 @@ "reservedNameError": "See nimi on reserveeritud. Palun vali mõni teine.", "specialCharactersError": "Nimi ei tohi sisaldada erimärke. Palun vali mõni teine.", "personalCode": "Isikukood", - "teamOwnerPersonalCodeLabel": "Omaniku isikukood" + "teamOwnerPersonalCodeLabel": "Omaniku isikukood", + "distributedBenefitsAmount": "Väljastatud toetus" } diff --git a/public/locales/ru/teams.json b/public/locales/ru/teams.json index 9eaa712..9f4125f 100644 --- a/public/locales/ru/teams.json +++ b/public/locales/ru/teams.json @@ -193,5 +193,6 @@ "reservedNameError": "Это имя зарезервировано. Пожалуйста, выберите другое.", "specialCharactersError": "Это имя не может содержать специальные символы. Пожалуйста, выберите другое.", "personalCode": "Идентификационный код", - "teamOwnerPersonalCodeLabel": "Идентификационный код владельца" + "teamOwnerPersonalCodeLabel": "Идентификационный код владельца", + "distributedBenefitsAmount": "Распределенные выплаты" } From 92dd79212195d233fe908b05c25a10813046eb64 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 15:46:01 +0300 Subject: [PATCH 15/21] feat(MED-97): update translations in each language --- public/locales/en/account.json | 5 +++- public/locales/en/billing.json | 11 ++++----- public/locales/en/cart.json | 6 ++++- public/locales/en/common.json | 6 ++--- public/locales/en/dashboard.json | 6 ++++- public/locales/en/teams.json | 39 +++++++++++++++++++++++++++++--- public/locales/et/account.json | 5 +++- public/locales/et/billing.json | 13 +++++------ public/locales/et/cart.json | 6 ++++- public/locales/et/common.json | 8 +++---- public/locales/et/dashboard.json | 7 +++++- public/locales/et/teams.json | 14 ++++++------ public/locales/ru/account.json | 5 +++- public/locales/ru/billing.json | 9 ++++---- public/locales/ru/cart.json | 6 ++++- public/locales/ru/common.json | 6 ++--- public/locales/ru/dashboard.json | 7 +++++- public/locales/ru/teams.json | 10 ++++---- 18 files changed, 117 insertions(+), 52 deletions(-) diff --git a/public/locales/en/account.json b/public/locales/en/account.json index e101985..a17036c 100644 --- a/public/locales/en/account.json +++ b/public/locales/en/account.json @@ -169,5 +169,8 @@ "updateAccountError": "Updating account details failed", "updateAccountPreferencesSuccess": "Account preferences updated", "updateAccountPreferencesError": "Updating account preferences failed", - "consents": "Consents" + "consents": "Consents", + "healthBenefitForm": { + "updateSuccess": "Health benefit updated" + } } diff --git a/public/locales/en/billing.json b/public/locales/en/billing.json index 88b2bbe..f8d12bc 100644 --- a/public/locales/en/billing.json +++ b/public/locales/en/billing.json @@ -121,7 +121,6 @@ "label": "Cart ({{ items }})" }, "pageTitle": "{{companyName}} budget", - "description": "Configure company budget..", "saveChanges": "Save changes", "healthBenefitForm": { "title": "Health benefit form", @@ -134,10 +133,10 @@ "monthly": "Monthly" }, "expensesOverview": { - "title": "Expenses overview 2025", - "monthly": "Expense per employee per month *", - "yearly": "Maximum expense per employee per year *", - "total": "Maximum expense per {{employeeCount}} employee(s) per year *", - "sum": "Total" + "title": "Health account budget overview 2025", + "employeeCount": "Health account users", + "managementFeeTotal": "Health account management fee {{managementFee}} per employee per month *", + "currentMonthUsageTotal": "Health account current month usage", + "total": "Health account budget total" } } diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 53f83ef..ad6225a 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -57,7 +57,10 @@ "order": { "title": "Order", "promotionsTotal": "Promotions total", + "companyBenefitsTotal": "Company benefits total", "subtotal": "Subtotal", + "benefitsTotal": "Paid with benefits", + "montonioTotal": "Paid with Montonio", "total": "Total", "giftCard": "Gift card" }, @@ -72,7 +75,8 @@ "orderNumber": "Order number", "orderStatus": "Order status", "paymentStatus": "Payment status", - "discount": "Discount" + "discount": "Discount", + "paymentConfirmationLoading": "Payment confirmation..." }, "montonioCallback": { "title": "Montonio checkout", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index f2dd9df..0fd868e 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -75,10 +75,10 @@ "orderAnalysis": "Order analysis", "orderHealthAnalysis": "Order health check", "account": "Account", - "members": "Members", + "companyMembers": "Manage employees", "billing": "Billing", - "dashboard": "Dashboard", - "settings": "Settings", + "companyDashboard": "Dashboard", + "companySettings": "Settings", "profile": "Profile", "pickTime": "Pick time", "preferences": "Preferences", diff --git a/public/locales/en/dashboard.json b/public/locales/en/dashboard.json index c92f438..03079b6 100644 --- a/public/locales/en/dashboard.json +++ b/public/locales/en/dashboard.json @@ -17,9 +17,13 @@ "orderAnalysis": { "title": "Order analysis", "description": "Select an analysis to get started" + }, + "benefits": { + "title": "Your company benefits" } }, "recommendations": { - "title": "Medreport recommends" + "title": "Medreport recommends", + "validUntil": "Valid until {{date}}" } } diff --git a/public/locales/en/teams.json b/public/locales/en/teams.json index 7bc7f6e..f0a728b 100644 --- a/public/locales/en/teams.json +++ b/public/locales/en/teams.json @@ -1,6 +1,12 @@ { "home": { - "pageTitle": "Company Dashboard" + "pageTitle": "Company Dashboard", + "headerTitle": "{{companyName}} Health Dashboard", + "healthDetails": "Company Health Details", + "membersSettingsButtonTitle": "Manage Members", + "membersSettingsButtonDescription": "Add, edit, or remove members.", + "membersBillingButtonTitle": "Manage Billing", + "membersBillingButtonDescription": "Select how you want to distribute the budget between members." }, "settings": { "pageTitle": "Company Settings", @@ -18,6 +24,32 @@ "billing": { "pageTitle": "Company Billing" }, + "benefitStatistics": { + "budget": { + "title": "Company Health Account Balance", + "balance": "Budget Balance {{balance}}", + "volume": "Budget Volume" + }, + "data": { + "reservations": "{{value}} services", + "analysis": "Analyses", + "doctorsAndSpecialists": "Doctors and Specialists", + "researches": "Researches", + "analysisPackages": "Health Analysis Packages", + "analysisPackagesCount": "{{value}} service usage", + "totalSum": "Total Sum", + "eclinic": "E-Clinic" + } + }, + "healthDetails": { + "women": "Women", + "men": "Men", + "avgAge": "Average Age", + "bmi": "BMI", + "cholesterol": "Cholesterol", + "vitaminD": "Vitamin D", + "smokers": "Smokers" + }, "yourTeams": "Your Companies ({{teamsCount}})", "createTeam": "Create a Company", "creatingTeam": "Creating Company...", @@ -28,7 +60,7 @@ "youLabel": "You", "emailLabel": "Email", "roleLabel": "Role", - "primaryOwnerLabel": "Primary Admin", + "primaryOwnerLabel": "Manager", "joinedAtLabel": "Joined at", "invitedAtLabel": "Invited at", "inviteMembersPageSubheading": "Invite members to your Company", @@ -153,7 +185,7 @@ "signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.", "acceptInvitationHeading": "Accept Invitation to join {{accountName}}", "acceptInvitationDescription": "You have been invited to join the company {{accountName}}. If you wish to accept the invitation, please click the button below.", - "continueAs": "Continue as {{email}}", + "continueAs": "Continue as {{fullName}}", "joinTeamAccount": "Join Company", "joiningTeam": "Joining company...", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.", @@ -161,5 +193,6 @@ "reservedNameError": "This name is reserved. Please choose a different one.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.", "personalCode": "Personal Code", + "teamOwnerPersonalCodeLabel": "Owner's Personal Code", "distributedBenefitsAmount": "Assigned benefits" } diff --git a/public/locales/et/account.json b/public/locales/et/account.json index bb89f86..4dee3e5 100644 --- a/public/locales/et/account.json +++ b/public/locales/et/account.json @@ -169,5 +169,8 @@ "updateAccountError": "Konto andmete uuendamine ebaõnnestus", "updateAccountPreferencesSuccess": "Konto eelistused uuendatud", "updateAccountPreferencesError": "Konto eelistused uuendamine ebaõnnestus", - "consents": "Nõusolekud" + "consents": "Nõusolekud", + "healthBenefitForm": { + "updateSuccess": "Tervisekonto andmed uuendatud" + } } diff --git a/public/locales/et/billing.json b/public/locales/et/billing.json index 14734c6..1abb884 100644 --- a/public/locales/et/billing.json +++ b/public/locales/et/billing.json @@ -121,11 +121,10 @@ "label": "Cart ({{ items }})" }, "pageTitle": "{{companyName}} eelarve", - "description": "Muuda ettevõtte eelarve seadistusi.", "saveChanges": "Salvesta muudatused", "healthBenefitForm": { "title": "Tervisetoetuse vorm", - "description": "Ettevõtte Tervisekassa toetus töötajale", + "description": "Ettevõtte tervisekonto seadistamine", "info": "* Hindadele lisanduvad riigipoolsed maksud" }, "occurrence": { @@ -134,10 +133,10 @@ "monthly": "Kord kuus" }, "expensesOverview": { - "title": "Kulude ülevaade 2025 aasta raames", - "monthly": "Kulu töötaja kohta kuus *", - "yearly": "Maksimaalne kulu inimese kohta kokku aastas *", - "total": "Maksimaalne kulu {{employeeCount}} töötaja kohta aastas *", - "sum": "Kokku" + "title": "Tervisekonto eelarve ülevaade 2025", + "employeeCount": "Tervisekonto kasutajate arv", + "managementFeeTotal": "Tervisekonto haldustasu {{managementFee}} kuus töötaja kohta*", + "currentMonthUsageTotal": "Tervisekonto jooksva kuu kasutus", + "total": "Tervisekonto eelarve maht kokku" } } diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index 7f38792..06c4039 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -57,7 +57,10 @@ "order": { "title": "Tellimus", "promotionsTotal": "Soodustuse summa", + "companyBenefitsTotal": "Toetuse summa", "subtotal": "Vahesumma", + "benefitsTotal": "Tasutud tervisetoetusest", + "montonioTotal": "Tasutud Montonio'ga", "total": "Summa", "giftCard": "Kinkekaart" }, @@ -72,7 +75,8 @@ "orderNumber": "Tellimuse number", "orderStatus": "Tellimuse olek", "paymentStatus": "Makse olek", - "discount": "Soodus" + "discount": "Soodus", + "paymentConfirmationLoading": "Makse kinnitamine..." }, "montonioCallback": { "title": "Montonio makseprotsess", diff --git a/public/locales/et/common.json b/public/locales/et/common.json index f0d2c3d..cfaba55 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -75,10 +75,10 @@ "orderAnalysis": "Telli analüüs", "orderHealthAnalysis": "Telli terviseuuring", "account": "Konto", - "members": "Liikmed", - "billing": "Arveldamine", - "dashboard": "Ülevaade", - "settings": "Seaded", + "companyMembers": "Töötajate haldamine", + "billing": "Eelarve", + "companyDashboard": "Ülevaade", + "companySettings": "Seaded", "profile": "Profiil", "pickTime": "Vali aeg", "preferences": "Eelistused", diff --git a/public/locales/et/dashboard.json b/public/locales/et/dashboard.json index d18c230..1b518f0 100644 --- a/public/locales/et/dashboard.json +++ b/public/locales/et/dashboard.json @@ -17,9 +17,14 @@ "orderAnalysis": { "title": "Telli analüüs", "description": "Telli endale sobiv analüüs" + }, + "benefits": { + "title": "Sinu Medreport konto seis", + "validUntil": "Kehtiv kuni {{date}}" } }, "recommendations": { - "title": "Medreport soovitab teile" + "title": "Medreport soovitab teile", + "validUntil": "Kehtiv kuni {{date}}" } } diff --git a/public/locales/et/teams.json b/public/locales/et/teams.json index 74376f5..f506595 100644 --- a/public/locales/et/teams.json +++ b/public/locales/et/teams.json @@ -1,7 +1,7 @@ { "home": { "pageTitle": "Ettevõtte ülevaade", - "headerTitle": "{{companyName}} Tervisekassa kokkuvõte", + "headerTitle": "{{companyName}} tervise ülevaade", "healthDetails": "Ettevõtte terviseandmed", "membersSettingsButtonTitle": "Halda töötajaid", "membersSettingsButtonDescription": "Lisa, muuda või eemalda töötajaid.", @@ -28,16 +28,16 @@ "budget": { "title": "Ettevõtte Tervisekassa seis", "balance": "Eelarve jääk {{balance}}", - "volume": "Eelarve maht {{volume}}" + "volume": "Eelarve maht" }, "data": { "reservations": "{{value}} teenust", "analysis": "Analüüsid", "doctorsAndSpecialists": "Eriarstid ja spetsialistid", "researches": "Uuringud", - "healthResearchPlans": "Terviseuuringute paketid", - "serviceUsage": "{{value}} teenuse kasutust", - "serviceSum": "Teenuste summa", + "analysisPackages": "Terviseuuringute paketid", + "analysisPackagesCount": "{{value}} teenuse kasutust", + "totalSum": "Tellitud teenuste summa", "eclinic": "Digikliinik" } }, @@ -60,7 +60,7 @@ "youLabel": "Sina", "emailLabel": "E-post", "roleLabel": "Roll", - "primaryOwnerLabel": "Peaadministraator", + "primaryOwnerLabel": "Haldur", "joinedAtLabel": "Liitus", "invitedAtLabel": "Kutsutud", "inviteMembersPageSubheading": "Kutsu töötajaid oma ettevõttesse", @@ -185,7 +185,7 @@ "signInWithDifferentAccountDescription": "Kui soovid kutse vastu võtta teise kontoga, logi välja ja tagasi sisse soovitud kontoga.", "acceptInvitationHeading": "Võta kutse vastu, et liituda ettevõttega {{accountName}}", "acceptInvitationDescription": "Sind on kutsutud liituma ettevõttega {{accountName}}. Kui soovid kutse vastu võtta, vajuta allolevat nuppu.", - "continueAs": "Jätka kui {{email}}", + "continueAs": "Jätka kui {{fullName}}", "joinTeamAccount": "Liitu ettevõttega", "joiningTeam": "Ettevõttega liitumine...", "leaveTeamInputLabel": "Palun kirjuta LEAVE kinnituseks, et ettevõttest lahkuda.", diff --git a/public/locales/ru/account.json b/public/locales/ru/account.json index 9eca7c0..fe66124 100644 --- a/public/locales/ru/account.json +++ b/public/locales/ru/account.json @@ -169,5 +169,8 @@ "updateAccountError": "Не удалось обновить данные аккаунта", "updateAccountPreferencesSuccess": "Предпочтения аккаунта обновлены", "updateAccountPreferencesError": "Не удалось обновить предпочтения аккаунта", - "consents": "Согласия" + "consents": "Согласия", + "healthBenefitForm": { + "updateSuccess": "Данные о выгоде обновлены" + } } diff --git a/public/locales/ru/billing.json b/public/locales/ru/billing.json index 90cdbfe..399a75c 100644 --- a/public/locales/ru/billing.json +++ b/public/locales/ru/billing.json @@ -121,7 +121,6 @@ "label": "Корзина ({{ items }})" }, "pageTitle": "Бюджет {{companyName}}", - "description": "Измените настройки бюджета компании.", "saveChanges": "Сохранить изменения", "healthBenefitForm": { "title": "Форма здоровья", @@ -135,9 +134,9 @@ }, "expensesOverview": { "title": "Обзор расходов за 2025 год", - "monthly": "Расход на одного сотрудника в месяц *", - "yearly": "Максимальный расход на одного человека в год *", - "total": "Максимальный расход на {{employeeCount}} сотрудников в год *", - "sum": "Итого" + "employeeCount": "Сотрудники корпоративного фонда здоровья", + "managementFeeTotal": "Расходы на управление корпоративным фондом здоровья {{managementFee}} в месяц на одного сотрудника *", + "currentMonthUsageTotal": "Расходы на корпоративный фонд здоровья в текущем месяце", + "total": "Общая сумма расходов на корпоративный фонд здоровья" } } diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index fb1a4d5..56decf2 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -57,7 +57,10 @@ "order": { "title": "Заказ", "promotionsTotal": "Скидка", + "companyBenefitsTotal": "Скидка компании", "subtotal": "Промежуточный итог", + "benefitsTotal": "Оплачено за счет выгод", + "montonioTotal": "Оплачено за счет Montonio", "total": "Сумма", "giftCard": "Подарочная карта" }, @@ -72,7 +75,8 @@ "orderNumber": "Номер заказа", "orderStatus": "Статус заказа", "paymentStatus": "Статус оплаты", - "discount": "Скидка" + "discount": "Скидка", + "paymentConfirmationLoading": "Ожидание подтверждения оплаты..." }, "montonioCallback": { "title": "Процесс оплаты Montonio", diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index ca97132..510b4a1 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -75,10 +75,10 @@ "orderAnalysis": "Заказать анализ", "orderHealthAnalysis": "Заказать обследование", "account": "Аккаунт", - "members": "Участники", + "companyMembers": "Участники", "billing": "Оплата", - "dashboard": "Обзор", - "settings": "Настройки", + "companyDashboard": "Обзор", + "companySettings": "Настройки", "profile": "Профиль", "pickTime": "Выбрать время", "preferences": "Предпочтения", diff --git a/public/locales/ru/dashboard.json b/public/locales/ru/dashboard.json index 9c0a7d6..523e70c 100644 --- a/public/locales/ru/dashboard.json +++ b/public/locales/ru/dashboard.json @@ -17,9 +17,14 @@ "orderAnalysis": { "title": "Заказать анализ", "description": "Закажите подходящий для вас анализ" + }, + "benefits": { + "title": "Ваш счет Medreport", + "validUntil": "Действителен до {{date}}" } }, "recommendations": { - "title": "Medreport recommends" + "title": "Medreport рекомендует", + "validUntil": "Действителен до {{date}}" } } diff --git a/public/locales/ru/teams.json b/public/locales/ru/teams.json index 9f4125f..aa61b70 100644 --- a/public/locales/ru/teams.json +++ b/public/locales/ru/teams.json @@ -28,16 +28,16 @@ "budget": { "title": "Баланс Tervisekassa компании", "balance": "Остаток бюджета {{balance}}", - "volume": "Объем бюджета {{volume}}" + "volume": "Объем бюджета" }, "data": { "reservations": "{{value}} услуги", "analysis": "Анализы", "doctorsAndSpecialists": "Врачи и специалисты", "researches": "Исследования", - "healthResearchPlans": "Пакеты медицинских исследований", - "serviceUsage": "{{value}} использование услуг", - "serviceSum": "Сумма услуг", + "analysisPackages": "Пакеты медицинских исследований", + "analysisPackagesCount": "{{value}} использование услуг", + "totalSum": "Сумма услуг", "eclinic": "Дигиклиника" } }, @@ -185,7 +185,7 @@ "signInWithDifferentAccountDescription": "Если вы хотите принять приглашение с другим аккаунтом, выйдите из системы и войдите с нужным аккаунтом.", "acceptInvitationHeading": "Принять приглашение для присоединения к {{accountName}}", "acceptInvitationDescription": "Вас пригласили присоединиться к компании {{accountName}}. Чтобы принять приглашение, нажмите кнопку ниже.", - "continueAs": "Продолжить как {{email}}", + "continueAs": "Продолжить как {{fullName}}", "joinTeamAccount": "Присоединиться к компании", "joiningTeam": "Присоединение к компании...", "leaveTeamInputLabel": "Пожалуйста, введите LEAVE для подтверждения выхода из компании.", From a68f7c7ab5fda779aaccbc4379a8379f016c5981 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 15:46:26 +0300 Subject: [PATCH 16/21] feat(MED-97): save `benefit_distribution_schedule_id` to `account_balance_entries` --- packages/supabase/src/database.types.ts | 9 + ...250926135946_include_benefit_config_id.sql | 219 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 supabase/migrations/20250926135946_include_benefit_config_id.sql diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 58ffac2..4653429 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -2066,6 +2066,15 @@ export type Database = { user_id: string }[] } + get_benefits_usages_for_company_members: { + Args: { + p_account_id: string + } + Returns: { + personal_account_id: string + benefit_amount: number + } + } get_config: { Args: Record Returns: Json diff --git a/supabase/migrations/20250926135946_include_benefit_config_id.sql b/supabase/migrations/20250926135946_include_benefit_config_id.sql new file mode 100644 index 0000000..cd6b448 --- /dev/null +++ b/supabase/migrations/20250926135946_include_benefit_config_id.sql @@ -0,0 +1,219 @@ +ALTER TABLE medreport.account_balance_entries ADD COLUMN benefit_distribution_schedule_id uuid; + +-- Also setting `benefit_distribution_schedule_id` value now +drop function if exists medreport.distribute_health_benefits(uuid, numeric, text); +create or replace function medreport.distribute_health_benefits( + p_benefit_distribution_schedule_id uuid +) +returns void +language plpgsql +security definer +as $$ +declare + member_record record; + expires_date timestamp with time zone; + v_company_id uuid; + v_benefit_amount numeric; +begin + -- Expires on first day of next year. + expires_date := date_trunc('year', now() + interval '1 year'); + + -- Get company_id and benefit_amount from benefit_distribution_schedule + select company_id, benefit_amount into v_company_id, v_benefit_amount + from medreport.benefit_distribution_schedule + where id = p_benefit_distribution_schedule_id; + + -- Get all personal accounts that are members of this company + for member_record in + select distinct a.id as personal_account_id + from medreport.accounts a + join medreport.accounts_memberships am on a.id = am.user_id + where am.account_id = v_company_id + and a.is_personal_account = true + loop + -- Check if there is already a balance entry for this personal account from the same company in same month + if exists ( + select 1 + from medreport.account_balance_entries + where entry_type = 'benefit' + and account_id = member_record.personal_account_id + and source_company_id = v_company_id + and date_trunc('month', created_at) = date_trunc('month', now()) + ) then + continue; + end if; + + -- Insert balance entry for each personal account + insert into medreport.account_balance_entries ( + account_id, + amount, + entry_type, + description, + source_company_id, + created_by, + expires_at, + benefit_distribution_schedule_id + ) values ( + member_record.personal_account_id, + v_benefit_amount, + 'benefit', + 'Health benefit from company', + v_company_id, + auth.uid(), + expires_date, + p_benefit_distribution_schedule_id + ); + end loop; +end; +$$; + +grant execute on function medreport.distribute_health_benefits(uuid) to authenticated, service_role; + +create or replace function medreport.process_periodic_benefit_distributions() +returns void +language plpgsql +as $$ +declare + schedule_record record; + next_distribution_date timestamp with time zone; +begin + -- Get all active schedules that are due for distribution + for schedule_record in + select * + from medreport.benefit_distribution_schedule + where is_active = true + and next_distribution_at <= now() + loop + -- Distribute benefits + perform medreport.distribute_health_benefits( + schedule_record.id + ); + + -- Calculate next distribution date + next_distribution_date := medreport.calculate_next_distribution_date( + schedule_record.benefit_occurrence, + now() + ); + + -- Update the schedule + update medreport.benefit_distribution_schedule + set + last_distributed_at = now(), + next_distribution_at = next_distribution_date, + updated_at = now() + where id = schedule_record.id; + end loop; +end; +$$; + +DROP FUNCTION IF EXISTS medreport.upsert_benefit_distribution_schedule(uuid,numeric,text); +create or replace function medreport.upsert_benefit_distribution_schedule( + p_company_id uuid, + p_benefit_amount numeric, + p_benefit_occurrence text +) +-- Return schedule row id +returns uuid +language plpgsql +as $$ +declare + next_distribution_date timestamp with time zone; + record_id uuid; +begin + -- Calculate next distribution date + next_distribution_date := medreport.calculate_next_distribution_date(p_benefit_occurrence); + + -- Check if there's an existing record for this company + select id into record_id + from medreport.benefit_distribution_schedule + where company_id = p_company_id + limit 1; + + if record_id is not null then + -- Update existing record + update medreport.benefit_distribution_schedule + set + benefit_amount = p_benefit_amount, + benefit_occurrence = p_benefit_occurrence, + next_distribution_at = next_distribution_date, + is_active = true, + updated_at = now() + where id = record_id; + else + record_id := gen_random_uuid(); + + -- Insert new record + insert into medreport.benefit_distribution_schedule ( + id, + company_id, + benefit_amount, + benefit_occurrence, + next_distribution_at + ) values ( + record_id, + p_company_id, + p_benefit_amount, + p_benefit_occurrence, + next_distribution_date + ); + end if; + + return record_id; +end; +$$; + +grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to authenticated, service_role; + +create or replace function medreport.trigger_distribute_benefits() +returns trigger +language plpgsql +security definer +as $$ +declare + v_benefit_distribution_schedule_id uuid; +begin + -- Only distribute if benefit_amount is set and greater than 0 + if new.benefit_amount is not null and new.benefit_amount > 0 then + -- Create or update the distribution schedule for future distributions + v_benefit_distribution_schedule_id := medreport.upsert_benefit_distribution_schedule( + new.account_id, + new.benefit_amount, + coalesce(new.benefit_occurance, 'yearly') + ); + + -- Distribute benefits to all company members immediately + perform medreport.distribute_health_benefits( + v_benefit_distribution_schedule_id + ); + else + -- If benefit_amount is 0 or null, deactivate the schedule + update medreport.benefit_distribution_schedule + set is_active = false, updated_at = now() + where company_id = new.account_id; + end if; + + return new; +end; +$$; + + +CREATE OR REPLACE FUNCTION medreport.get_benefits_usages_for_company_members(p_account_id uuid) +returns table ( + personal_account_id uuid, + benefit_amount numeric +) +language plpgsql +as $$ +begin + return query + select + abe.account_id as personal_account_id, + sum(abe.amount) as benefit_amount + from medreport.account_balance_entries abe + where abe.source_company_id = p_account_id + and abe.entry_type = 'benefit' + group by abe.account_id; +end; +$$; + +grant execute on function medreport.get_benefits_usages_for_company_members(uuid) to authenticated, service_role; From 27689dbbffc649b43cbea9f6da9b454910d87fb6 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 15:46:52 +0300 Subject: [PATCH 17/21] feat(MED-97): hide company logo upload if it doesn't work --- .../team-account-settings-container.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx index c49ae1e..cfa629c 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx @@ -13,6 +13,8 @@ import { TeamAccountDangerZone } from './team-account-danger-zone'; import { UpdateTeamAccountImage } from './update-team-account-image-container'; import { UpdateTeamAccountNameForm } from './update-team-account-name-form'; +const SHOW_TEAM_LOGO = false as boolean; + export function TeamAccountSettingsContainer(props: { account: { name: string; @@ -32,21 +34,23 @@ export function TeamAccountSettingsContainer(props: { }) { return (
- - - - - + {SHOW_TEAM_LOGO && ( + + + + + - - - - + + + + - - - - + + + + + )} From bfdd1ec62a3eab59a457c22f66a3c98c170980d1 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Fri, 26 Sep 2025 15:58:25 +0300 Subject: [PATCH 18/21] add tto order --- .../(dashboard)/order/[orderId]/page.tsx | 19 +++++++------------ .../(user)/_components/booking/time-slots.tsx | 8 +++++--- .../_components/order/order-details.tsx | 8 ++++++-- .../(user)/_components/orders/order-block.tsx | 5 ++++- .../_components/orders/order-items-table.tsx | 16 ++++++++-------- .../medusa-storefront/src/lib/data/orders.ts | 5 ----- 6 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx index e6bf47d..f28b5ae 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx @@ -26,17 +26,7 @@ async function OrderConfirmedPage(props: { params: Promise<{ orderId: string }>; }) { const params = await props.params; - - const order = await getAnalysisOrder({ - analysisOrderId: Number(params.orderId), - }).catch(() => null); - if (!order) { - redirect(pathsConfig.app.myOrders); - } - - const medusaOrder = await retrieveOrder(order.medusa_order_id).catch( - () => null, - ); + const medusaOrder = await retrieveOrder(params.orderId).catch(() => null); if (!medusaOrder) { redirect(pathsConfig.app.myOrders); } @@ -46,7 +36,12 @@ async function OrderConfirmedPage(props: { } />
- + diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index 4e28adc..4d5fc14 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -171,9 +171,11 @@ const TimeSlots = ({ reservationId, newStartTime: timeSlot.StartTime, newServiceId: Number(syncedService.id), - timeSlot.UserID, - timeSlot.SyncUserID, - booking.selectedLocationId ? booking.selectedLocationId : null, + newAppointmentUserId: timeSlot.UserID, + newSyncUserId: timeSlot.SyncUserID, + newLocationId: booking.selectedLocationId + ? booking.selectedLocationId + : null, cartId, }); diff --git a/app/home/(user)/_components/order/order-details.tsx b/app/home/(user)/_components/order/order-details.tsx index 8dcff3f..0107790 100644 --- a/app/home/(user)/_components/order/order-details.tsx +++ b/app/home/(user)/_components/order/order-details.tsx @@ -4,14 +4,18 @@ import { Trans } from '@kit/ui/trans'; import type { AnalysisOrder } from '~/lib/types/order'; -export default function OrderDetails({ order }: { order: AnalysisOrder }) { +export default function OrderDetails({ + order, +}: { + order: { id: string; created_at: string | Date }; +}) { return (
:{' '} - {order.medusa_order_id} + {order.id}
diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index ef6ab6d..08daf05 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -64,7 +64,10 @@ export default function OrderBlock({ items={itemsTtoService} title="orders:table.ttoService" type="ttoService" - order={{ status: medusaOrderStatus.toUpperCase() }} + order={{ + status: medusaOrderStatus.toUpperCase(), + medusaOrderId, + }} /> )} { + const openDetailedView = async () => { if (isAnalysisOrder && order?.medusaOrderId && order?.id) { await logAnalysisResultsNavigateAction(order.medusaOrderId); router.push(`${pathsConfig.app.analysisResults}/${order.id}`); + } else { + router.push(`${pathsConfig.app.myOrders}/${order.medusaOrderId}`); } }; @@ -88,13 +90,11 @@ export default function OrderItemsTable({ /> - {isAnalysisOrder && ( - - - - )} + + + ))} diff --git a/packages/features/medusa-storefront/src/lib/data/orders.ts b/packages/features/medusa-storefront/src/lib/data/orders.ts index c79c579..6473ab0 100644 --- a/packages/features/medusa-storefront/src/lib/data/orders.ts +++ b/packages/features/medusa-storefront/src/lib/data/orders.ts @@ -61,11 +61,6 @@ export const listOrders = async ( }; export const createTransferRequest = async ( - state: { - success: boolean; - error: string | null; - order: HttpTypes.StoreOrder | null; - }, formData: FormData, ): Promise<{ success: boolean; From 2d9e6f8df3dcca866557b46e417b736de2f6de00 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 16:34:10 +0300 Subject: [PATCH 19/21] feat(MED-97): display accounts count, usage total --- .../team-account-benefit-statistics.tsx | 50 ++++++++++++------- .../_components/team-account-statistics.tsx | 7 ++- .../load-team-account-benefit-statistics.ts | 2 +- app/home/[account]/page.tsx | 6 +++ public/locales/en/teams.json | 6 ++- public/locales/et/teams.json | 10 ++-- public/locales/ru/teams.json | 6 ++- 7 files changed, 58 insertions(+), 29 deletions(-) diff --git a/app/home/[account]/_components/team-account-benefit-statistics.tsx b/app/home/[account]/_components/team-account-benefit-statistics.tsx index 035ff9d..81f232d 100644 --- a/app/home/[account]/_components/team-account-benefit-statistics.tsx +++ b/app/home/[account]/_components/team-account-benefit-statistics.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { formatCurrency } from '@/packages/shared/src/utils'; -import { PiggyBankIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Card, CardTitle } from '@kit/ui/card'; import { cn } from '@kit/ui/lib/utils'; import { Trans } from '@kit/ui/trans'; +import { TeamAccountBenefitExpensesOverview } from '../_lib/server/load-team-account-benefit-expenses-overview'; import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benefit-statistics'; const StatisticsCard = ({ children }: { children: React.ReactNode }) => { @@ -38,8 +38,10 @@ const StatisticsValue = ({ children }: { children: React.ReactNode }) => { const TeamAccountBenefitStatistics = ({ accountBenefitStatistics, + expensesOverview, }: { accountBenefitStatistics: AccountBenefitStatistics; + expensesOverview: TeamAccountBenefitExpensesOverview; }) => { const { i18n: { language }, @@ -47,25 +49,16 @@ const TeamAccountBenefitStatistics = ({ return (
- -
-
- -
- - +
+ + + - {formatCurrency({ - value: accountBenefitStatistics.periodTotal, - locale: language, - currencyCode: 'EUR', - })} + {accountBenefitStatistics.companyAccountsCount} -
- + -
@@ -79,11 +72,30 @@ const TeamAccountBenefitStatistics = ({ + + + + + + {formatCurrency({ + value: expensesOverview.currentMonthUsageTotal, + locale: language, + currencyCode: 'EUR', + })} + + + - {accountBenefitStatistics.orders.analysesSum} € + + {formatCurrency({ + value: accountBenefitStatistics.orders.analysesSum, + locale: language, + currencyCode: 'EUR', + })} + diff --git a/app/home/[account]/_components/team-account-statistics.tsx b/app/home/[account]/_components/team-account-statistics.tsx index bae53a4..17a7466 100644 --- a/app/home/[account]/_components/team-account-statistics.tsx +++ b/app/home/[account]/_components/team-account-statistics.tsx @@ -20,6 +20,7 @@ import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benef import TeamAccountBenefitStatistics from './team-account-benefit-statistics'; import TeamAccountHealthDetails from './team-account-health-details'; import type { Account, AccountParams, BmiThresholds } from '@/packages/features/accounts/src/types/accounts'; +import { TeamAccountBenefitExpensesOverview } from '../_lib/server/load-team-account-benefit-expenses-overview'; export interface TeamAccountStatisticsProps { teamAccount: Account; @@ -27,6 +28,7 @@ export interface TeamAccountStatisticsProps { bmiThresholds: Omit[]; members: Database['medreport']['Functions']['get_account_members']['Returns']; accountBenefitStatistics: AccountBenefitStatistics; + expensesOverview: TeamAccountBenefitExpensesOverview; } export default function TeamAccountStatistics({ @@ -35,6 +37,7 @@ export default function TeamAccountStatistics({ bmiThresholds, members, accountBenefitStatistics, + expensesOverview, }: TeamAccountStatisticsProps) { const currentDate = new Date(); const [date, setDate] = useState({ @@ -50,7 +53,7 @@ export default function TeamAccountStatistics({ return ( <> -
+

- +
diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts index 4de4be5..f61f350 100644 --- a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts +++ b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts @@ -29,7 +29,7 @@ export const loadCompanyPersonalAccountsBalanceEntries = async ({ const { count, data: accountMemberships } = await supabase .schema('medreport') .from('accounts_memberships') - .select('user_id') + .select('user_id', { count: 'exact' }) .eq('account_id', accountId) .throwOnError(); diff --git a/app/home/[account]/page.tsx b/app/home/[account]/page.tsx index 5cff5d3..e8eb7ba 100644 --- a/app/home/[account]/page.tsx +++ b/app/home/[account]/page.tsx @@ -18,6 +18,7 @@ import { import { Dashboard } from './_components/dashboard'; import { loadAccountBenefitStatistics } from './_lib/server/load-team-account-benefit-statistics'; +import { loadTeamAccountBenefitExpensesOverview } from './_lib/server/load-team-account-benefit-expenses-overview'; interface TeamAccountHomePageProps { params: Promise<{ account: string }>; @@ -41,6 +42,10 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const { memberParams, members } = use(teamAccountsApi.getMembers(account)); const bmiThresholds = use(userAnalysesApi.fetchBmiThresholds()); const accountBenefitStatistics = use(loadAccountBenefitStatistics(teamAccount.id)); + const expensesOverview = use(loadTeamAccountBenefitExpensesOverview({ + companyId: teamAccount.id, + employeeCount: members.length, + })); use( createPageViewLog({ @@ -57,6 +62,7 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { bmiThresholds={bmiThresholds} members={members} accountBenefitStatistics={accountBenefitStatistics} + expensesOverview={expensesOverview} /> ); diff --git a/public/locales/en/teams.json b/public/locales/en/teams.json index f0a728b..8af264f 100644 --- a/public/locales/en/teams.json +++ b/public/locales/en/teams.json @@ -28,7 +28,8 @@ "budget": { "title": "Company Health Account Balance", "balance": "Budget Balance {{balance}}", - "volume": "Budget Volume" + "volume": "Budget Volume", + "membersCount": "Members Count" }, "data": { "reservations": "{{value}} services", @@ -38,7 +39,8 @@ "analysisPackages": "Health Analysis Packages", "analysisPackagesCount": "{{value}} service usage", "totalSum": "Total Sum", - "eclinic": "E-Clinic" + "eclinic": "E-Clinic", + "currentMonthUsageTotal": "Current Month Usage" } }, "healthDetails": { diff --git a/public/locales/et/teams.json b/public/locales/et/teams.json index f506595..884923d 100644 --- a/public/locales/et/teams.json +++ b/public/locales/et/teams.json @@ -28,17 +28,19 @@ "budget": { "title": "Ettevõtte Tervisekassa seis", "balance": "Eelarve jääk {{balance}}", - "volume": "Eelarve maht" + "volume": "Eelarve maht", + "membersCount": "Töötajate arv" }, "data": { - "reservations": "{{value}} teenust", + "reservations": "{{value}} tellimus(t)", "analysis": "Analüüsid", "doctorsAndSpecialists": "Eriarstid ja spetsialistid", "researches": "Uuringud", "analysisPackages": "Terviseuuringute paketid", - "analysisPackagesCount": "{{value}} teenuse kasutust", + "analysisPackagesCount": "{{value}} tellimus(t)", "totalSum": "Tellitud teenuste summa", - "eclinic": "Digikliinik" + "eclinic": "Digikliinik", + "currentMonthUsageTotal": "Kasutatud eelarve" } }, "healthDetails": { diff --git a/public/locales/ru/teams.json b/public/locales/ru/teams.json index aa61b70..74111f1 100644 --- a/public/locales/ru/teams.json +++ b/public/locales/ru/teams.json @@ -28,7 +28,8 @@ "budget": { "title": "Баланс Tervisekassa компании", "balance": "Остаток бюджета {{balance}}", - "volume": "Объем бюджета" + "volume": "Объем бюджета", + "membersCount": "Количество сотрудников" }, "data": { "reservations": "{{value}} услуги", @@ -38,7 +39,8 @@ "analysisPackages": "Пакеты медицинских исследований", "analysisPackagesCount": "{{value}} использование услуг", "totalSum": "Сумма услуг", - "eclinic": "Дигиклиника" + "eclinic": "Дигиклиника", + "currentMonthUsageTotal": "Текущее использование бюджета" } }, "healthDetails": { From 6bdf5fbf12e4aa6c7e4ff58cabf3ca16f7b3350a Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 17:01:35 +0300 Subject: [PATCH 20/21] feat(MED-97): fix duplicate element --- app/home/(user)/_components/cart/index.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index c289b7b..5f884a5 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -211,10 +211,6 @@ export default function Cart({ cart={{ ...cart }} synlabAnalyses={synlabAnalyses} /> - )} From f794a66147b0e90efcc09443e79d4516e8091d93 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 17:01:56 +0300 Subject: [PATCH 21/21] feat(MED-97): fix `new OpenAI()` throws error when key is missing in env --- app/home/(user)/_lib/server/is-valid-open-ai-env.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/home/(user)/_lib/server/is-valid-open-ai-env.ts b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts index d6dbfae..183a8f9 100644 --- a/app/home/(user)/_lib/server/is-valid-open-ai-env.ts +++ b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts @@ -1,9 +1,8 @@ import OpenAI from 'openai'; export const isValidOpenAiEnv = async () => { - const client = new OpenAI(); - try { + const client = new OpenAI(); await client.models.list(); return true; } catch (e) {