From f7514c698eec3c7f1dca76f7067712802692462c Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 3 Sep 2025 10:04:00 +0300 Subject: [PATCH 01/47] feat: implement booking feature with service and time slot selection --- .../(dashboard)/booking/[handle]/page.tsx | 13 ++- .../_components/booking/booking-container.tsx | 31 ++++++ .../_components/booking/booking.context.ts | 18 ++++ .../_components/booking/booking.provider.tsx | 47 +++++++++ .../_components/booking/location-selector.tsx | 9 ++ .../_components/booking/service-selector.tsx | 83 ++++++++++++++++ .../(user)/_components/booking/time-slots.tsx | 96 +++++++++++++++++++ .../(user)/_components/service-categories.tsx | 6 +- app/home/(user)/_lib/server/load-category.ts | 1 + lib/services/connected-online.service.ts | 12 +-- packages/ui/src/shadcn/radio-group.tsx | 4 +- 11 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 app/home/(user)/_components/booking/booking-container.tsx create mode 100644 app/home/(user)/_components/booking/booking.context.ts create mode 100644 app/home/(user)/_components/booking/booking.provider.tsx create mode 100644 app/home/(user)/_components/booking/location-selector.tsx create mode 100644 app/home/(user)/_components/booking/service-selector.tsx create mode 100644 app/home/(user)/_components/booking/time-slots.tsx diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index ebce187..a125346 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -2,12 +2,13 @@ import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-he import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; -import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; +import BookingContainer from '../../../_components/booking/booking-container'; + export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); const title = i18n.t('booking:title'); @@ -18,9 +19,13 @@ export const generateMetadata = async () => { }; async function BookingHandlePage({ params }: { params: { handle: string } }) { - const handle = await params.handle; + const { handle } = await params; const { category } = await loadCategory({ handle }); + if (!category) { + return
Category not found
; + } + return ( <> } - description={} + description="" /> - + ); } diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx new file mode 100644 index 0000000..fb760fa --- /dev/null +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; + +import { Calendar } from '@kit/ui/shadcn/calendar'; +import { Card } from '@kit/ui/shadcn/card'; + +import { ServiceCategory } from '../service-categories'; +import { BookingProvider } from './booking.provider'; +import LocationSelector from './location-selector'; +import ServiceSelector from './service-selector'; +import TimeSlots from './time-slots'; + +const BookingContainer = ({ category }: { category: ServiceCategory }) => { + return ( + +
+
+ + + + + {/* */} +
+ +
+
+ ); +}; + +export default BookingContainer; diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts new file mode 100644 index 0000000..36d9035 --- /dev/null +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +import { StoreProduct } from '@medusajs/types'; +import { noop } from 'lodash'; + +const BookingContext = createContext<{ + timeSlots: string[]; + selectedService: StoreProduct | null; + setSelectedService: (selectedService: any) => void; + updateTimeSlots: (serviceId: number) => Promise; +}>({ + timeSlots: [], + selectedService: null, + setSelectedService: (_) => _, + updateTimeSlots: async (_) => noop(), +}); + +export { BookingContext }; diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx new file mode 100644 index 0000000..700ceee --- /dev/null +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; + +import { StoreProduct } from '@medusajs/types'; + +import { getAvailableAppointmentsForService } from '~/lib/services/connected-online.service'; + +import { ServiceCategory } from '../service-categories'; +import { BookingContext } from './booking.context'; + +export function useBooking() { + const context = React.useContext(BookingContext); + + if (!context) { + throw new Error('useBooking must be used within a BookingProvider.'); + } + + return context; +} + +export const BookingProvider: React.FC<{ + children: React.ReactElement; + category: ServiceCategory; +}> = ({ children, category }) => { + const [selectedService, setSelectedService] = useState( + category.products[0] || null, + ); + const [timeSlots, setTimeSlots] = useState([]); + + const updateTimeSlots = async (serviceId: number) => { + const response = await getAvailableAppointmentsForService(serviceId); + console.log('updateTimeSlots response', response); + // Fetch time slots based on the selected service ID + }; + + return ( + + {children} + + ); +}; diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx new file mode 100644 index 0000000..e2e7de1 --- /dev/null +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { Card } from '@kit/ui/shadcn/card'; + +const LocationSelector = () => { + return LocationSelector; +}; + +export default LocationSelector; diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx new file mode 100644 index 0000000..557048e --- /dev/null +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; + +import { StoreProduct } from '@medusajs/types'; +import { ArrowUp, ChevronDown } from 'lucide-react'; + +import { Button } from '@kit/ui/shadcn/button'; +import { Card } from '@kit/ui/shadcn/card'; +import { Label } from '@kit/ui/shadcn/label'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@kit/ui/shadcn/popover'; +import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group'; + +import { useBooking } from './booking.provider'; + +const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { + const { selectedService, setSelectedService, updateTimeSlots } = useBooking(); + const [collapsed, setCollapsed] = React.useState(false); + const [firstFourProducts, setFirstFourProducts] = useState( + products.slice(0, 4), + ); + + const onServiceSelect = async (productId: StoreProduct['id']) => { + const product = products.find((p) => p.id === productId); + setSelectedService(product); + setCollapsed(false); + await updateTimeSlots((product!.metadata!.serviceId as number) || 0); + }; + + console.log('selectedService', selectedService); + return ( + +
Teenused
+ +
+ + {firstFourProducts.map((product) => ( +
+ + +
+ ))} +
+ +
setCollapsed((_) => !_)} + className="flex cursor-pointer items-center justify-between border-t py-1" + > + Kuva kõik + +
+
+
+ + + {products.map((product) => ( +
+ + +
+ ))} +
+
+
+
+ ); +}; + +export default ServiceSelector; diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx new file mode 100644 index 0000000..5860e80 --- /dev/null +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { formatCurrency } from '@/packages/shared/src/utils'; +import { format } from 'date-fns'; + +import { Button } from '@kit/ui/shadcn/button'; +import { Card } from '@kit/ui/shadcn/card'; +import { Trans } from '@kit/ui/trans'; + +import { AvailableAppointmentsResponse } from '~/lib/types/connected-online'; + +const dummyData: AvailableAppointmentsResponse['Data']['T_Booking'] = [ + { + ServiceID: 1, + StartTime: new Date('2024-10-10T10:00:00Z'), + EndTime: new Date('2024-10-10T11:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, + { + ServiceID: 1, + StartTime: new Date('2024-10-10T11:00:00Z'), + EndTime: new Date('2024-10-10T12:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, + { + ServiceID: 2, + StartTime: new Date('2024-10-10T12:00:00Z'), + EndTime: new Date('2024-10-10T13:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, +]; + +const TimeSlots = () => { + return ( +
+ {dummyData.map((data) => ( + +
+ {format(data.StartTime.toString(), 'HH:mm')} +
+
+ Dr. Jüri Mardikas +
+ Kardioloog + Tervisekassa aeg +
+
+ + Ülemiste Tervisemaja 2 + + + Ülemiste füsioteraapiakliinik + + + Sepapaja 2/1 + + Tallinn +
+
+
+ + {formatCurrency({ + currencyCode: 'EUR', + locale: 'et-EE', + value: 20, + })} + + +
+
+ ))} +
+ ); +}; + +export default TimeSlots; diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx index 9ef3e25..4e0aa20 100644 --- a/app/home/(user)/_components/service-categories.tsx +++ b/app/home/(user)/_components/service-categories.tsx @@ -4,17 +4,19 @@ import React from 'react'; import { redirect } from 'next/navigation'; -import { createPath, pathsConfig } from '@/packages/shared/src/config'; +import { pathsConfig } from '@/packages/shared/src/config'; +import { StoreProduct } from '@medusajs/types'; import { ComponentInstanceIcon } from '@radix-ui/react-icons'; import { cn } from '@kit/ui/shadcn'; -import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card'; +import { Card, CardDescription } from '@kit/ui/shadcn/card'; export interface ServiceCategory { name: string; handle: string; color: string; description: string; + products: StoreProduct[]; } const ServiceCategories = ({ diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts index 2c0479c..1a2514c 100644 --- a/app/home/(user)/_lib/server/load-category.ts +++ b/app/home/(user)/_lib/server/load-category.ts @@ -25,6 +25,7 @@ async function categoryLoader({ description: category?.description || '', handle: category?.handle || '', name: category?.name || '', + products: category?.products || [], }, }; } diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index b0ad1f6..e0628cc 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -51,12 +51,12 @@ export async function getAvailableAppointmentsForService( : `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`; } - await logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.GetAvailabilities, - RequestStatus.Fail, - comment, - ); + // await logRequestResult( + // ExternalApi.ConnectedOnline, + // ConnectedOnlineMethodName.GetAvailabilities, + // RequestStatus.Fail, + // comment, + // ); return null; } diff --git a/packages/ui/src/shadcn/radio-group.tsx b/packages/ui/src/shadcn/radio-group.tsx index 7779e73..f98a019 100644 --- a/packages/ui/src/shadcn/radio-group.tsx +++ b/packages/ui/src/shadcn/radio-group.tsx @@ -25,12 +25,12 @@ const RadioGroupItem: React.FC< return ( - + From 22f7fa134b5aba980071031e708b59d536204803 Mon Sep 17 00:00:00 2001 From: Helena Date: Wed, 17 Sep 2025 18:11:13 +0300 Subject: [PATCH 02/47] MED-103: add booking functionality --- app/api/job/handler/sync-connected-online.ts | 220 ++++++++-- .../(dashboard)/booking/[handle]/page.tsx | 6 +- .../cart/montonio-callback/actions.ts | 234 ++++++---- .../montonio-callback/client-component.tsx | 9 +- .../cart/montonio-callback/error/page.tsx | 32 +- app/home/(user)/(dashboard)/cart/page.tsx | 73 +++- app/home/(user)/(dashboard)/order/page.tsx | 61 ++- .../_components/booking/booking-container.tsx | 45 +- .../_components/booking/booking.context.ts | 65 ++- .../_components/booking/booking.provider.tsx | 51 ++- .../_components/booking/location-selector.tsx | 55 ++- .../_components/booking/service-selector.tsx | 24 +- .../(user)/_components/booking/time-slots.tsx | 319 ++++++++++---- .../_components/order-analyses-cards.tsx | 1 - .../(user)/_components/orders/order-block.tsx | 15 +- .../_components/orders/order-items-table.tsx | 37 +- .../(user)/_components/service-categories.tsx | 1 + app/home/(user)/_lib/server/actions.ts | 65 +++ app/home/(user)/_lib/server/load-analyses.ts | 4 - app/home/(user)/_lib/server/load-category.ts | 37 +- .../(user)/_lib/server/load-tto-services.ts | 22 +- lib/services/audit.service.ts | 39 +- lib/services/connected-online.service.ts | 410 ++++++++++++++---- lib/services/medusaCart.service.ts | 14 +- lib/services/order.service.ts | 26 ++ lib/types/connected-online.ts | 25 +- lib/utils.ts | 11 +- .../src/emails/book-time-failed.email.tsx | 61 +++ packages/email-templates/src/index.ts | 1 + .../medusa-storefront/src/lib/data/cart.ts | 23 +- .../src/lib/data/categories.ts | 2 - .../supabase/src/clients/server-client.ts | 2 +- packages/supabase/src/database.types.ts | 232 ++++++++-- packages/ui/src/shadcn/calendar.tsx | 2 +- public/locales/en/booking.json | 4 +- public/locales/et/booking.json | 12 +- public/locales/et/cart.json | 4 +- public/locales/et/common.json | 3 +- public/locales/et/orders.json | 22 +- public/locales/ru/booking.json | 5 +- .../20250908092531_add_clinic_key.sql | 11 + ...onnected_online_service_provider_table.sql | 60 +++ ...fields_to_connected_online_reservation.sql | 14 + ...6_add_connected_online_locations_table.sql | 43 ++ 44 files changed, 1923 insertions(+), 479 deletions(-) create mode 100644 app/home/(user)/_lib/server/actions.ts create mode 100644 packages/email-templates/src/emails/book-time-failed.email.tsx create mode 100644 supabase/migrations/20250908092531_add_clinic_key.sql create mode 100644 supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql create mode 100644 supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql create mode 100644 supabase/migrations/20250915101146_add_connected_online_locations_table.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 39a5fb3..2cc0447 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,8 +1,42 @@ import axios from 'axios'; +import { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { ISearchLoadResponse } from '~/lib/types/connected-online'; +import { logSyncResult } from '~/lib/services/audit.service'; +import { SyncStatus } from '~/lib/types/audit'; +import type { + ISearchLoadResponse, + P_JobTitleTranslation, +} from '~/lib/types/connected-online'; + +function createTranslationMap(translations: P_JobTitleTranslation[]) { + const result: Map< + number, + Map + > = new Map(); + + for (const translation of translations) { + const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation; + + if (!result.has(ClinicID)) { + result.set(ClinicID, new Map()); + } + + result.get(ClinicID)!.set(SyncID, { + textET: TextET, + textEN: TextEN, + textRU: TextRU, + }); + } + + return result; +} + +function getSpokenLanguages(spokenLanguages?: string) { + if (!spokenLanguages || !spokenLanguages.length) return []; + return spokenLanguages.split(','); +} export default async function syncConnectedOnline() { const isProd = process.env.NODE_ENV === 'production'; @@ -16,14 +50,19 @@ export default async function syncConnectedOnline() { const supabase = getSupabaseServerAdminClient(); try { - const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + const searchLoadResponse = await axios.post<{ d: string }>( + `${baseUrl}/Search_Load`, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + param: "{'Value':'|et|-1'}", // get all available services in Estonian }, - param: "{'Value':'|et|-1'}", // get all available services in Estonian - }); + ); - const responseData: ISearchLoadResponse = JSON.parse(response.data.d); + const responseData: ISearchLoadResponse = JSON.parse( + searchLoadResponse.data.d, + ); if (responseData?.ErrorCode !== 0) { throw new Error('Failed to get Connected Online data'); @@ -43,16 +82,35 @@ export default async function syncConnectedOnline() { let clinics; let services; + let serviceProviders; + let jobTitleTranslations; // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment + const isDemoClinic = (clinicId: number) => clinicId === 2; if (isProd) { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2); + clinics = responseData.Data.T_Lic.filter(({ ID }) => !isDemoClinic(ID)); services = responseData.Data.T_Service.filter( - (service) => service.ClinicID !== 2, + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ), ); } else { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2); - services = responseData.Data.T_Service.filter( - (service) => service.ClinicID === 2, + clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); + services = responseData.Data.T_Service.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ), ); } @@ -64,6 +122,8 @@ export default async function syncConnectedOnline() { name: clinic.Name, personal_code_required: !!clinic.PersonalCodeRequired, phone_number: clinic.Phone || null, + key: clinic.Key, + address: clinic.Address, }; }); @@ -87,45 +147,133 @@ export default async function syncConnectedOnline() { }; }); + const mappedServiceProviders = serviceProviders.map((serviceProvider) => { + const jobTitleTranslation = serviceProvider.JobTitleID + ? jobTitleTranslations + .get(serviceProvider.ClinicID) + ?.get(serviceProvider.JobTitleID) + : null; + return { + id: serviceProvider.ID, + prefix: serviceProvider.Prefix, + name: serviceProvider.Name, + spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages), + job_title_et: jobTitleTranslation?.textET, + job_title_en: jobTitleTranslation?.textEN, + job_title_ru: jobTitleTranslation?.textRU, + job_title_id: serviceProvider.JobTitleID, + is_deleted: !!serviceProvider.Deleted, + clinic_id: serviceProvider.ClinicID, + }; + }); + const { error: providersError } = await supabase .schema('medreport') .from('connected_online_providers') .upsert(mappedClinics); + if (providersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online providers: ' + + JSON.stringify(providersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + const { error: servicesError } = await supabase .schema('medreport') .from('connected_online_services') - .upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false }); + .upsert(mappedServices, { + onConflict: 'id', + ignoreDuplicates: false, + }); - if (providersError || servicesError) { - return supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - comment: providersError - ? 'Error saving providers: ' + JSON.stringify(providersError) - : 'Error saving services: ' + JSON.stringify(servicesError), - status: 'FAIL', - changed_by_role: 'service_role', - }); + if (servicesError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online services: ' + + JSON.stringify(servicesError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); } - await supabase.schema('audit').from('sync_entries').insert({ + const { error: serviceProvidersError } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .upsert(mappedServiceProviders, { + onConflict: 'id', + ignoreDuplicates: false, + }); + + if (serviceProvidersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving service providers: ' + + JSON.stringify(serviceProvidersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + + for (const mappedClinic of mappedClinics) { + const defaultLoadResponse = await axios.post<{ d: string }>( + `${baseUrl}/Default_Load`, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + param: `{'Value':'${mappedClinic.key}|et'}`, + }, + ); + + const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d); + + if (defaultLoadResponseData?.ErrorCode !== 0) { + throw new Error('Failed to get Connected Online location data'); + } + + const clinicLocations: { + SyncID: number; + Address: string; + Name: string; + }[] = defaultLoadResponseData.Data.T_SelectableLocation; + + if (clinicLocations?.length) { + const mappedLocations = clinicLocations.map( + ({ SyncID, Address, Name }) => ({ + address: Address, + clinic_id: mappedClinic.id, + sync_id: SyncID, + name: Name, + }), + ); + + await supabase + .schema('medreport') + .from('connected_online_locations') + .insert(mappedLocations) + .throwOnError(); + } + } + + await logSyncResult({ operation: 'CONNECTED_ONLINE_SYNC', - status: 'SUCCESS', + status: SyncStatus.Success, changed_by_role: 'service_role', }); } catch (e) { - await supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - status: 'FAIL', - comment: JSON.stringify(e), - changed_by_role: 'service_role', - }); + await logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + status: SyncStatus.Fail, + comment: JSON.stringify(e), + changed_by_role: 'service_role', + }); throw new Error( `Failed to sync Connected Online data, error: ${JSON.stringify(e)}`, ); diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index a125346..3d9c29e 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -18,7 +18,11 @@ export const generateMetadata = async () => { }; }; -async function BookingHandlePage({ params }: { params: { handle: string } }) { +async function BookingHandlePage({ + params, +}: { + params: Promise<{ handle: string }>; +}) { const { handle } = await params; const { category } = await loadCategory({ handle }); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index f705399..222be00 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -1,45 +1,61 @@ 'use server'; +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 jwt from 'jsonwebtoken'; -import { z } from "zod"; -import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types"; -import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account"; -import { listProductTypes } from "@lib/data/products"; -import { placeOrder, retrieveCart } from "@lib/data/cart"; -import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; -import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; -import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; +import { z } from 'zod'; + +import type { AccountWithParams } from '@kit/accounts/api'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { AccountWithParams } from '@kit/accounts/api'; -import type { StoreOrder } from '@medusajs/types'; + +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { + bookAppointment, + getOrderedTtoServices, +} from '~/lib/services/connected-online.service'; +import { + getOrderedAnalysisIds, + sendOrderToMedipost, +} from '~/lib/services/medipost.service'; +import { + createAnalysisOrder, + getAnalysisOrder, +} from '~/lib/services/order.service'; + +import { FailureReason } from '../../../../../../lib/types/connected-online'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; +const TTO_SERVICE_TYPE_HANDLE = 'tto-service'; 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({ +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', - }); + }) + .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, @@ -48,15 +64,17 @@ const sendEmail = async ({ partnerLocationName, language, }: { - account: Pick, - email: string, - analysisPackageName: string, - partnerLocationName: string, - language: string, + account: Pick; + email: string; + analysisPackageName: string; + partnerLocationName: string; + language: string; }) => { const client = getSupabaseServerAdminClient(); try { - const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates'); + const { renderSynlabAnalysisPackageEmail } = await import( + '@kit/email-templates' + ); const { getMailer } = await import('@kit/mailers'); const mailer = await getMailer(); @@ -78,15 +96,14 @@ const sendEmail = async ({ .catch((error) => { throw new Error(`Failed to send email, message=${error}`); }); - await createNotificationsApi(client) - .createNotification({ - account_id: account.id, - body: html, - }); + 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; @@ -96,7 +113,7 @@ async function decodeOrderToken(orderToken: string) { }) as MontonioOrderToken; if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { - throw new Error("Payment not successful"); + throw new Error('Payment not successful'); } return decoded; @@ -105,38 +122,49 @@ async function decodeOrderToken(orderToken: string) { async function getCartByOrderToken(decoded: MontonioOrderToken) { const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); if (!cartId) { - throw new Error("Cart ID not found"); + throw new Error('Cart ID not found'); } const cart = await retrieveCart(cartId); if (!cart) { - throw new Error("Cart not found"); + throw new Error('Cart not found'); } 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 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); + 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 ?? '', - })) + 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, }; } @@ -145,12 +173,12 @@ async function sendAnalysisPackageOrderEmail({ email, analysisPackageOrder, }: { - account: AccountWithParams, - email: string, + account: AccountWithParams; + email: string; analysisPackageOrder: { - partnerLocationName: string, - analysisPackageName: string, - }, + partnerLocationName: string; + analysisPackageName: string; + }; }) { const { language } = await createI18nServerInstance(); const { analysisPackageName, partnerLocationName } = analysisPackageOrder; @@ -163,60 +191,114 @@ async function sendAnalysisPackageOrderEmail({ language, }); } catch (error) { - console.error("Failed to send email", error); + console.error('Failed to send email', error); } } export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { - throw new Error("Account not found in context"); + throw new Error('Account not found in context'); } try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); + + const orderContainsSynlabItems = !!orderedAnalysisElements?.length; try { - const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id }); - console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`); + 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 }); + let orderId; + if (orderContainsSynlabItems) { + orderId = await createAnalysisOrder({ + medusaOrder, + orderedAnalysisElements, + }); + } + const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; + + const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); + let bookServiceResults: { + success: boolean; + reason?: FailureReason; + serviceId?: number; + }[] = []; + if (orderedTtoServices?.length) { + const bookingPromises = orderedTtoServices.map((service) => + bookAppointment( + service.service_id, + service.clinic_id, + service.service_user_id, + service.sync_user_id, + service.start_time, + ), + ); + bookServiceResults = await Promise.all(bookingPromises); + } if (email) { if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ account, email, 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`); + 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); + console.error('Missing email to send order result email', orderResult); } - if (env().isEnabledDispatchOnMontonioCallback) { + if (env().isEnabledDispatchOnMontonioCallback && orderContainsSynlabItems) { await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); } + if (bookServiceResults.some(({ success }) => success === false)) { + const failedServiceBookings = bookServiceResults.filter( + ({ success }) => success === false, + ); + return { + success: false, + failedServiceBookings, + orderId, + }; + } + return { success: true, orderId }; } catch (error) { - console.error("Failed to place order", error); + console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); } } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx index c388a6d..af495ae 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx @@ -29,8 +29,13 @@ export default function MontonioCallbackClient({ orderToken, error }: { setHasProcessed(true); try { - const { orderId } = await processMontonioCallback(orderToken); - router.push(`/home/order/${orderId}/confirmed`); + const result = await processMontonioCallback(orderToken); + if (result.success) { + return router.push(`/home/order/${result.orderId}/confirmed`); + } + if (result.failedServiceBookings?.length){ + router.push(`/home/cart/montonio-callback/error?reasonFailed=${result.failedServiceBookings.map(({reason}) => reason).join(',')}`); + } } catch (error) { console.error("Failed to place order", error); router.push('/home/cart/montonio-callback/error'); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx index cdb64ff..3fac68c 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -1,11 +1,17 @@ +import { use } from 'react'; + import Link from 'next/link'; -import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; -import { Trans } from '@kit/ui/trans'; +import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; + +import { Button } from '@kit/ui/button'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert'; -import { Button } from '@kit/ui/button'; +import { Trans } from '@kit/ui/trans'; + +import { FailureReason } from '~/lib/types/connected-online'; +import { toArray } from '~/lib/utils'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -15,7 +21,15 @@ export async function generateMetadata() { }; } -export default async function MontonioCheckoutCallbackErrorPage() { +export default async function MontonioCheckoutCallbackErrorPage({ + searchParams, +}: { + searchParams: Promise<{ reasonFailed: string }>; +}) { + const params = await searchParams; + + const failedBookingData: string[] = toArray(params.reasonFailed?.split(',')); + return (
} /> @@ -27,9 +41,15 @@ export default async function MontonioCheckoutCallbackErrorPage() { -

+ {failedBookingData.length ? ( + failedBookingData.map((failureReason, index) => ( +

+ +

+ )) + ) : ( -

+ )}
diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 1bb2675..20efdce 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -1,15 +1,18 @@ -import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; -import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; - 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'; -import Cart from '../../_components/cart'; import { listProductTypes } from '@lib/data/products'; -import CartTimer from '../../_components/cart/cart-timer'; + import { Trans } from '@kit/ui/trans'; + import { withI18n } from '~/lib/i18n/with-i18n'; +import { findProductTypeIdByHandle } from '~/lib/utils'; +import Cart from '../../_components/cart'; +import CartTimer from '../../_components/cart/cart-timer'; + export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -20,34 +23,62 @@ export async function generateMetadata() { async function CartPage() { const cart = await retrieveCart().catch((error) => { - console.error("Failed to retrieve cart", error); + console.error('Failed to retrieve cart', error); return notFound(); }); const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); - const synlabAnalysisType = productTypes.find(({ metadata }) => metadata?.handle === 'synlab-analysis'); - const synlabAnalyses = analysisPackagesType && synlabAnalysisType && cart?.items - ? cart.items.filter((item) => { - const productTypeId = item.product?.type_id; - if (!productTypeId) { - return false; - } - return [analysisPackagesType.id, synlabAnalysisType.id].includes(productTypeId); - }) - : []; - const ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? []; - const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1); + const synlabAnalysisTypeId = findProductTypeIdByHandle( + productTypes, + 'synlab-analysis', + ); + const analysisPackagesTypeId = findProductTypeIdByHandle( + productTypes, + 'analysis-packages', + ); + const ttoServiceTypeId = findProductTypeIdByHandle( + productTypes, + 'tto-service', + ); + + const synlabAnalyses = + analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items + ? cart.items.filter((item) => { + const productTypeId = item.product?.type_id; + if (!productTypeId) { + return false; + } + return [analysisPackagesTypeId, synlabAnalysisTypeId].includes( + productTypeId, + ); + }) + : []; + const ttoServiceItems = + ttoServiceTypeId && cart?.items + ? cart?.items?.filter((item) => { + const productTypeId = item.product?.type_id; + return productTypeId && productTypeId === ttoServiceTypeId; + }) + : []; + + const otherItemsSorted = ttoServiceItems.sort((a, b) => + (a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1, + ); const item = otherItemsSorted[0]; - const isTimerShown = ttoServiceItems.length > 0 && !!item && !!item.updated_at; + const isTimerShown = + ttoServiceItems.length > 0 && !!item && !!item.updated_at; return ( }> {isTimerShown && } - + ); } diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 48faae3..2e9fe68 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -1,18 +1,22 @@ +import React from 'react'; + import { redirect } from 'next/navigation'; -import { listOrders } from '~/medusa/lib/data/orders'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { listProductTypes } from '@lib/data/products'; -import { PageBody } from '@kit/ui/makerkit/page'; -import { pathsConfig } from '@kit/shared/config'; - -import { Trans } from '@kit/ui/trans'; -import { HomeLayoutPageHeader } from '../../_components/home-page-header'; -import { getAnalysisOrders } from '~/lib/services/order.service'; -import OrderBlock from '../../_components/orders/order-block'; -import React from 'react'; import { Divider } from '@medusajs/ui'; + +import { pathsConfig } from '@kit/shared/config'; +import { PageBody } from '@kit/ui/makerkit/page'; +import { Trans } from '@kit/ui/trans'; + import { withI18n } from '~/lib/i18n/with-i18n'; +import { getAnalysisOrders, getTtoOrders } from '~/lib/services/order.service'; +import { findProductTypeIdByHandle } from '~/lib/utils'; +import { listOrders } from '~/medusa/lib/data/orders'; + +import { HomeLayoutPageHeader } from '../../_components/home-page-header'; +import OrderBlock from '../../_components/orders/order-block'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -25,13 +29,21 @@ export async function generateMetadata() { async function OrdersPage() { const medusaOrders = await listOrders(); const analysisOrders = await getAnalysisOrders(); + const ttoOrders = await getTtoOrders(); const { productTypes } = await listProductTypes(); - if (!medusaOrders || !productTypes) { + if (!medusaOrders || !productTypes || !ttoOrders) { redirect(pathsConfig.auth.signIn); } - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages')!; + const analysisPackagesTypeId = findProductTypeIdByHandle( + productTypes, + 'analysis-package', + ); + const ttoServiceTypeId = findProductTypeIdByHandle( + productTypes, + 'tto-service', + ); return ( <> @@ -40,26 +52,41 @@ async function OrdersPage() { description={} /> - {analysisOrders.map((analysisOrder) => { - const medusaOrder = medusaOrders.find(({ id }) => id === analysisOrder.medusa_order_id); + {medusaOrders.map((medusaOrder) => { + const analysisOrder = analysisOrders.find( + ({ medusa_order_id }) => medusa_order_id === medusaOrder.id, + ); if (!medusaOrder) { return null; } const medusaOrderItems = medusaOrder.items || []; - const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter((item) => item.product_type_id === analysisPackagesType?.id); - const medusaOrderItemsOther = medusaOrderItems.filter((item) => item.product_type_id !== analysisPackagesType?.id); + const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter( + (item) => item.product_type_id === analysisPackagesTypeId, + ); + const medusaOrderItemsTtoServices = medusaOrderItems.filter( + (item) => item.product_type_id === ttoServiceTypeId, + ); + const medusaOrderItemsOther = medusaOrderItems.filter( + (item) => + !item.product_type_id || + ![analysisPackagesTypeId, ttoServiceTypeId].includes( + item.product_type_id, + ), + ); return ( - + - ) + ); })} {analysisOrders.length === 0 && (
diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index fb760fa..d7ca8cc 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,28 +1,57 @@ 'use client'; -import React from 'react'; +import { isBefore, isSameDay } from 'date-fns'; +import { uniq } from 'lodash'; import { Calendar } from '@kit/ui/shadcn/calendar'; import { Card } from '@kit/ui/shadcn/card'; import { ServiceCategory } from '../service-categories'; -import { BookingProvider } from './booking.provider'; +import { BookingProvider, useBooking } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; +const BookingCalendar = () => { + const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = + useBooking(); + const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); + + return ( + + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return ( + isBefore(date, today) || + !availableDates.some((dateWithBooking) => + isSameDay(date, dateWithBooking), + ) + ); + }} + className="rounded-md border" + {...(isLoadingTimeSlots && { + className: 'rounded-md border opacity-50 pointer-events-none', + })} + /> + + ); +}; + const BookingContainer = ({ category }: { category: ServiceCategory }) => { return ( -
+
- - - - {/* */} + +
- +
); diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index 36d9035..a8b47a4 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -3,16 +3,75 @@ import { createContext } from 'react'; import { StoreProduct } from '@medusajs/types'; import { noop } from 'lodash'; +import { Tables } from '@kit/supabase/database'; + +export type Location = Tables< + { schema: 'medreport' }, + 'connected_online_locations' +>; + +export type TimeSlotResponse = { + timeSlots: TimeSlot[]; + locations: Location[]; +}; + +export type TimeSlot = { + ClinicID: number; + LocationID: number; + UserID: number; + SyncUserID: number; + ServiceID: number; + HKServiceID: number; + StartTime: Date; + EndTime: Date; + PayorCode: string; + serviceProvider?: ServiceProvider; + syncedService?: SyncedService; +} & { location?: Location }; + +export type ServiceProvider = { + name: string; + id: number; + jobTitleEn: string | null; + jobTitleEt: string | null; + jobTitleRu: string | null; + clinicId: number; +}; + +export type SyncedService = Tables< + { schema: 'medreport' }, + 'connected_online_services' +> & { + providerClinic: ProviderClinic; +}; + +export type ProviderClinic = Tables< + { schema: 'medreport' }, + 'connected_online_providers' +> & { locations: Location[] }; + const BookingContext = createContext<{ - timeSlots: string[]; + timeSlots: TimeSlot[] | null; selectedService: StoreProduct | null; - setSelectedService: (selectedService: any) => void; + locations: Location[] | null; + selectedLocationId: number | null; + selectedDate?: Date; + isLoadingTimeSlots?: boolean; + setSelectedService: (selectedService?: StoreProduct) => void; + setSelectedLocationId: (selectedLocationId: number | null) => void; updateTimeSlots: (serviceId: number) => Promise; + setSelectedDate: (selectedDate?: Date) => void; }>({ - timeSlots: [], + timeSlots: null, selectedService: null, + locations: null, + selectedLocationId: null, + selectedDate: new Date(), + isLoadingTimeSlots: false, setSelectedService: (_) => _, + setSelectedLocationId: (_) => _, updateTimeSlots: async (_) => noop(), + setSelectedDate: (_) => _, }); export { BookingContext }; diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 700ceee..bf6e1f7 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { StoreProduct } from '@medusajs/types'; -import { getAvailableAppointmentsForService } from '~/lib/services/connected-online.service'; +import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service'; import { ServiceCategory } from '../service-categories'; -import { BookingContext } from './booking.context'; +import { BookingContext, Location, TimeSlot } from './booking.context'; export function useBooking() { - const context = React.useContext(BookingContext); + const context = useContext(BookingContext); if (!context) { throw new Error('useBooking must be used within a BookingProvider.'); @@ -24,21 +24,54 @@ export const BookingProvider: React.FC<{ const [selectedService, setSelectedService] = useState( category.products[0] || null, ); - const [timeSlots, setTimeSlots] = useState([]); + const [selectedLocationId, setSelectedLocationId] = useState( + null, + ); + const [selectedDate, setSelectedDate] = useState(); + const [timeSlots, setTimeSlots] = useState(null); + const [locations, setLocations] = useState(null); + const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); - const updateTimeSlots = async (serviceId: number) => { - const response = await getAvailableAppointmentsForService(serviceId); - console.log('updateTimeSlots response', response); - // Fetch time slots based on the selected service ID + useEffect(() => { + let metadataServiceIds = []; + try { + metadataServiceIds = JSON.parse( + selectedService?.metadata?.serviceIds as string, + ); + } catch (e) { + return; + } + if (metadataServiceIds.length) { + updateTimeSlots(metadataServiceIds); + } + }, [selectedService?.metadata?.serviceIds, selectedLocationId]); + + const updateTimeSlots = async (serviceIds: number[]) => { + setIsLoadingTimeSlots(true); + try { + const response = await getAvailableTimeSlotsForDisplay(serviceIds, selectedLocationId); + setTimeSlots(response.timeSlots); + setLocations(response.locations) + } catch (error) { + setTimeSlots(null); + } finally { + setIsLoadingTimeSlots(false); + } }; return ( {children} diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx index e2e7de1..f792fa5 100644 --- a/app/home/(user)/_components/booking/location-selector.tsx +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -1,9 +1,60 @@ -import React from 'react'; +import { Label } from '@medusajs/ui'; +import { useTranslation } from 'react-i18next'; +import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group'; import { Card } from '@kit/ui/shadcn/card'; +import { Trans } from '@kit/ui/trans'; + +import { useBooking } from './booking.provider'; const LocationSelector = () => { - return LocationSelector; + const { t } = useTranslation(); + const { + selectedService, + selectedLocationId, + setSelectedLocationId, + locations, + } = useBooking(); + + const onLocationSelect = (locationId: number | string | null) => { + if (locationId === 'all') return setSelectedLocationId(null); + setSelectedLocationId(Number(locationId)); + }; + + return ( + +
+ +
+
+ onLocationSelect(val)} + > +
+ + +
+ {locations?.map((location) => ( +
+ + +
+ ))} +
+
+
+ ); }; export default LocationSelector; diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 557048e..0c60a3e 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -1,9 +1,8 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { StoreProduct } from '@medusajs/types'; -import { ArrowUp, ChevronDown } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; -import { Button } from '@kit/ui/shadcn/button'; import { Card } from '@kit/ui/shadcn/card'; import { Label } from '@kit/ui/shadcn/label'; import { @@ -12,27 +11,26 @@ import { PopoverTrigger, } from '@kit/ui/shadcn/popover'; import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group'; +import { Trans } from '@kit/ui/trans'; import { useBooking } from './booking.provider'; const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { - const { selectedService, setSelectedService, updateTimeSlots } = useBooking(); - const [collapsed, setCollapsed] = React.useState(false); - const [firstFourProducts, setFirstFourProducts] = useState( - products.slice(0, 4), - ); + const { selectedService, setSelectedService } = useBooking(); + const [collapsed, setCollapsed] = useState(false); + const [firstFourProducts] = useState(products.slice(0, 4)); const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); setSelectedService(product); setCollapsed(false); - await updateTimeSlots((product!.metadata!.serviceId as number) || 0); }; - console.log('selectedService', selectedService); return ( -
Teenused
+
+ +
{ onClick={() => setCollapsed((_) => !_)} className="flex cursor-pointer items-center justify-between border-t py-1" > - Kuva kõik + + +
diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index 5860e80..d74befc 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -1,94 +1,257 @@ -import React from 'react'; +import { useMemo, useState } from 'react'; + +import { useRouter } from 'next/navigation'; import { formatCurrency } from '@/packages/shared/src/utils'; -import { format } from 'date-fns'; +import { addHours, isAfter, isSameDay } from 'date-fns'; +import { orderBy } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { pathsConfig } from '@kit/shared/config'; +import { formatDateAndTime } from '@kit/shared/utils'; import { Button } from '@kit/ui/shadcn/button'; import { Card } from '@kit/ui/shadcn/card'; +import { toast } from '@kit/ui/sonner'; import { Trans } from '@kit/ui/trans'; +import { cn } from '@kit/ui/utils'; -import { AvailableAppointmentsResponse } from '~/lib/types/connected-online'; +import { createInitialReservationAction } from '../../_lib/server/actions'; +import { ServiceProvider, TimeSlot } from './booking.context'; +import { useBooking } from './booking.provider'; -const dummyData: AvailableAppointmentsResponse['Data']['T_Booking'] = [ - { - ServiceID: 1, - StartTime: new Date('2024-10-10T10:00:00Z'), - EndTime: new Date('2024-10-10T11:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, - { - ServiceID: 1, - StartTime: new Date('2024-10-10T11:00:00Z'), - EndTime: new Date('2024-10-10T12:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, - { - ServiceID: 2, - StartTime: new Date('2024-10-10T12:00:00Z'), - EndTime: new Date('2024-10-10T13:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, -]; +const getServiceProviderTitle = ( + currentLocale: string, + serviceProvider?: ServiceProvider, +) => { + if (!serviceProvider) return null; + if (currentLocale === 'en') return serviceProvider.jobTitleEn; + if (currentLocale === 'ru') return serviceProvider.jobTitleRu; + + return serviceProvider.jobTitleEt; +}; + +const PAGE_SIZE = 7; + +const TimeSlots = ({ countryCode }: { countryCode: string }) => { + const [currentPage, setCurrentPage] = useState(1); + + const { + t, + i18n: { language: currentLocale }, + } = useTranslation(); + + const booking = useBooking(); + + const router = useRouter(); + + const selectedDate = booking.selectedDate ?? new Date(); + + const filteredBookings = useMemo( + () => + orderBy( + booking?.timeSlots?.filter(({ StartTime }) => { + const firstAvailableTimeToSelect = isSameDay(selectedDate, new Date()) + ? addHours(new Date(), 0.5) + : selectedDate; + return isAfter(StartTime, firstAvailableTimeToSelect); + }) ?? [], + 'StartTime', + 'asc', + ), + [booking.timeSlots, selectedDate], + ); + + const totalPages = Math.ceil(filteredBookings.length / PAGE_SIZE); + + const paginatedBookings = useMemo(() => { + const startIndex = (currentPage - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + return filteredBookings.slice(startIndex, endIndex); + }, [filteredBookings, currentPage, PAGE_SIZE]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const generatePageNumbers = () => { + const pages = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + if (currentPage <= 3) { + for (let i = 1; i <= 4; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - 2) { + pages.push(1); + pages.push('...'); + for (let i = totalPages - 3; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + pages.push('...'); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } + } + + return pages; + }; + + if (!booking?.timeSlots?.length) { + return null; + } + + const handleBookTime = async (timeSlot: TimeSlot, comments?: string) => { + const selectedService = booking.selectedService; + + const selectedVariant = selectedService?.variants?.[0]; + + const syncedService = timeSlot.syncedService; + if (!syncedService || !selectedVariant) { + return toast.error(t('booking:serviceNotFound')); + } + + const bookTimePromise = createInitialReservationAction( + selectedVariant, + countryCode, + Number(syncedService.id), + syncedService?.clinic_id, + timeSlot.UserID, + timeSlot.SyncUserID, + timeSlot.StartTime, + booking.selectedLocationId ? booking.selectedLocationId : null, + comments, + ).then(() => { + router.push(pathsConfig.app.cart); + }); + + toast.promise(() => bookTimePromise, { + success: , + error: , + loading: , + }); + }; -const TimeSlots = () => { return ( -
- {dummyData.map((data) => ( - -
- {format(data.StartTime.toString(), 'HH:mm')} -
-
- Dr. Jüri Mardikas -
- Kardioloog - Tervisekassa aeg -
-
- - Ülemiste Tervisemaja 2 - - - Ülemiste füsioteraapiakliinik - - - Sepapaja 2/1 - - Tallinn -
+
+
+ {paginatedBookings.map((timeSlot, index) => { + const isEHIF = timeSlot.HKServiceID > 0; + const serviceProviderTitle = getServiceProviderTitle( + currentLocale, + timeSlot.serviceProvider, + ); + const price = + booking.selectedService?.variants?.[0]?.calculated_price + ?.calculated_amount; + return ( + +
+ {formatDateAndTime(timeSlot.StartTime.toString())} +
+
+ {timeSlot.serviceProvider?.name} +
+ {serviceProviderTitle && ( + + {serviceProviderTitle} + + )} + {isEHIF && {t('booking:ehifBooking')}} +
+
+ {timeSlot.location?.address} +
+
+
+ + {formatCurrency({ + currencyCode: 'EUR', + locale: 'et-EE', + value: price ?? '', + })} + + +
+
+ ); + })} + + {!paginatedBookings.length && ( +
+

{t('booking:noResults')}

-
- - {formatCurrency({ - currencyCode: 'EUR', - locale: 'et-EE', - value: 20, - })} - -
+ + {totalPages > 1 && ( +
+
+ {t('common:pageOfPages', { + page: currentPage, + total: totalPages, + })} +
+ +
+ + + {generatePageNumbers().map((page, index) => ( + + ))} + +
- - ))} +
+ )}
); }; diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index bd7f8a7..bb6c36f 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -101,7 +101,6 @@ export default function OrderAnalysesCards({ {title} {description && ( <> - {' '} diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index 077d761..6833b0a 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -5,17 +5,19 @@ import OrderItemsTable from "./order-items-table"; import Link from "next/link"; import { Eye } from "lucide-react"; -export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsOther }: { - analysisOrder: AnalysisOrder, +export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsTtoService, itemsOther, medusaOrderId }: { + analysisOrder?: AnalysisOrder, itemsAnalysisPackage: StoreOrderLineItem[], + itemsTtoService: StoreOrderLineItem[], itemsOther: StoreOrderLineItem[], + medusaOrderId: string, }) { return (

- +

-
+ {analysisOrder &&
@@ -26,9 +28,10 @@ export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsO -
+
}
- + {analysisOrder && } + {itemsTtoService && }
diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index b1e4852..6032bb5 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation'; import { StoreOrderLineItem } from '@medusajs/types'; import { formatDate } from 'date-fns'; -import { Eye } from 'lucide-react'; import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; @@ -22,14 +21,18 @@ import { AnalysisOrder } from '~/lib/services/order.service'; import { logAnalysisResultsNavigateAction } from './actions'; +export type OrderItemType = 'analysisOrder' | 'ttoService'; + export default function OrderItemsTable({ items, title, analysisOrder, + type = 'analysisOrder', }: { items: StoreOrderLineItem[]; title: string; - analysisOrder: AnalysisOrder; + analysisOrder?: AnalysisOrder; + type?: OrderItemType; }) { const router = useRouter(); @@ -37,9 +40,13 @@ export default function OrderItemsTable({ return null; } + const isAnalysisOrder = type === 'analysisOrder'; + const openAnalysisResults = async () => { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + if (analysisOrder) { + await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); + router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + } }; return ( @@ -52,10 +59,10 @@ export default function OrderItemsTable({ - + - + {isAnalysisOrder && } @@ -65,7 +72,7 @@ export default function OrderItemsTable({ ) .map((orderItem) => ( - +

{orderItem.product_title}

@@ -76,14 +83,18 @@ export default function OrderItemsTable({
- + - - - + {isAnalysisOrder && ( + + + + )}
))}
diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx index 4e0aa20..6998b78 100644 --- a/app/home/(user)/_components/service-categories.tsx +++ b/app/home/(user)/_components/service-categories.tsx @@ -17,6 +17,7 @@ export interface ServiceCategory { color: string; description: string; products: StoreProduct[]; + countryCode: string; } const ServiceCategories = ({ diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts new file mode 100644 index 0000000..7543d34 --- /dev/null +++ b/app/home/(user)/_lib/server/actions.ts @@ -0,0 +1,65 @@ +'use server'; + +import { StoreProductVariant } from '@medusajs/types'; + +import { + bookAppointment, + createInitialReservation, +} from '~/lib/services/connected-online.service'; +import { handleAddToCart } from '~/lib/services/medusaCart.service'; + +import { updateLineItem } from '../../../../../packages/features/medusa-storefront/src/lib/data'; + +export async function bookTimeAction( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserId: number, + startTime: Date, + comments?: string, +) { + return bookAppointment( + serviceId, + clinicId, + appointmentUserId, + syncUserId, + startTime, + comments, + ); +} + +export async function createInitialReservationAction( + selectedVariant: Pick, + countryCode: string, + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserId: number, + startTime: Date, + locationId: number | null, + comments?: string, +) { + const { addedItem } = await handleAddToCart({ + selectedVariant, + countryCode, + }); + + if (addedItem) { + const reservation = await createInitialReservation( + serviceId, + clinicId, + appointmentUserId, + syncUserId, + startTime, + addedItem.id, + locationId, + comments, + ); + + await updateLineItem({ + lineId: addedItem.id, + quantity: addedItem.quantity, + metadata: { connectedOnlineReservationId: reservation.id }, + }); + } +} diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts index cd16e61..c2e26e5 100644 --- a/app/home/(user)/_lib/server/load-analyses.ts +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -45,10 +45,6 @@ async function analysesLoader() { }) : null; - const serviceCategories = productCategories.filter( - ({ parent_category }) => parent_category?.handle === 'tto-categories', - ); - return { analyses: categoryProducts?.response.products diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts index 1a2514c..72e22d4 100644 --- a/app/home/(user)/_lib/server/load-category.ts +++ b/app/home/(user)/_lib/server/load-category.ts @@ -1,20 +1,30 @@ import { cache } from 'react'; -import { getProductCategories } from '@lib/data'; +import { getProductCategories, listProducts } from '@lib/data'; -import { ServiceCategory } from '../../_components/service-categories'; - -async function categoryLoader({ - handle, -}: { - handle: string; -}): Promise<{ category: ServiceCategory | null }> { - const response = await getProductCategories({ - handle, - fields: '*products, is_active, metadata', - }); +import { loadCountryCodes } from './load-analyses'; +async function categoryLoader({ handle }: { handle: string }) { + const [response, countryCodes] = await Promise.all([ + getProductCategories({ + handle, + limit: 1, + }), + loadCountryCodes(), + ]); const category = response.product_categories[0]; + const countryCode = countryCodes[0]!; + + if (!response.product_categories?.[0]?.id) { + return { category: null }; + } + + const { + response: { products: categoryProducts }, + } = await listProducts({ + countryCode, + queryParams: { limit: 100, category_id: response.product_categories[0].id }, + }); return { category: { @@ -25,7 +35,8 @@ async function categoryLoader({ description: category?.description || '', handle: category?.handle || '', name: category?.name || '', - products: category?.products || [], + countryCode, + products: categoryProducts, }, }; } diff --git a/app/home/(user)/_lib/server/load-tto-services.ts b/app/home/(user)/_lib/server/load-tto-services.ts index 3bbc4e5..530f959 100644 --- a/app/home/(user)/_lib/server/load-tto-services.ts +++ b/app/home/(user)/_lib/server/load-tto-services.ts @@ -10,38 +10,36 @@ async function ttoServicesLoader() { }); const heroCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && metadata?.isHero, ); const ttoCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - !metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && !metadata?.isHero, ); return { heroCategories: - heroCategories.map( - ({ name, handle, metadata, description }) => ({ + heroCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], ttoCategories: - ttoCategories.map( - ({ name, handle, metadata, description }) => ({ + ttoCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], }; diff --git a/lib/services/audit.service.ts b/lib/services/audit.service.ts index 880c037..ebab1f0 100644 --- a/lib/services/audit.service.ts +++ b/lib/services/audit.service.ts @@ -1,14 +1,14 @@ - 'use server' - -import { RequestStatus } from '@/lib/types/audit'; +'use server'; + +import { RequestStatus, SyncStatus } from '@/lib/types/audit'; import { ConnectedOnlineMethodName } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { MedipostAction } from '@/lib/types/medipost'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; + export default async function logRequestResult( - /* personalCode: string, */ requestApi: keyof typeof ExternalApi, + requestApi: keyof typeof ExternalApi, requestApiMethod: `${ConnectedOnlineMethodName}` | `${MedipostAction}`, status: RequestStatus, comment?: string, @@ -16,11 +16,10 @@ export default async function logRequestResult( serviceId?: number, serviceProviderId?: number, ) { - const { error } = await getSupabaseServerClient() + const { error } = await getSupabaseServerAdminClient() .schema('audit') .from('request_entries') .insert({ - /* personal_code: personalCode, */ request_api: requestApi, request_api_method: requestApiMethod, requested_start_date: startTime, @@ -69,3 +68,29 @@ export async function getMedipostDispatchTries(medusaOrderId: string) { return data; } + +export async function logSyncResult({ + operation, + comment, + status, + changed_by_role, +}: { + operation: string; + comment?: string; + status: SyncStatus; + changed_by_role: string; +}) { + const { error } = await getSupabaseServerAdminClient() + .schema('audit') + .from('sync_entries') + .insert({ + operation, + comment, + status, + changed_by_role, + }); + + if (error) { + throw new Error('Failed to insert log entry, error: ' + error.message); + } +} diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index e0628cc..e3cdc5d 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -7,19 +7,30 @@ import { BookTimeResponse, ConfirmedLoadResponse, ConnectedOnlineMethodName, + FailureReason, } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { Tables } from '@/packages/supabase/src/database.types'; -import { createClient } from '@/utils/supabase/server'; +import { StoreOrder } from '@medusajs/types'; import axios from 'axios'; +import { uniq, uniqBy } from 'lodash'; + +import { renderBookTimeFailedEmail } from '@kit/email-templates'; +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { TimeSlotResponse } from '../../app/home/(user)/_components/booking/booking.context'; +import { sendEmailFromTemplate } from './mailer.service'; +import { handleDeleteCartItem } from './medusaCart.service'; export async function getAvailableAppointmentsForService( serviceId: number, + key: string, + locationId: number | null, startTime?: Date, ) { try { - const showTimesFrom = startTime ? { StartTime: startTime } : {}; - + const start = startTime ? { StartTime: startTime } : {}; const response = await axios.post( `${process.env.CONNECTED_ONLINE_URL!}/${ConnectedOnlineMethodName.GetAvailabilities}`, { @@ -28,9 +39,11 @@ export async function getAvailableAppointmentsForService( }, param: JSON.stringify({ ServiceID: serviceId, - Key: '7T624nlu', + Key: key, Lang: 'et', - ...showTimesFrom, + MaxDays: 120, + LocationId: locationId ?? -1, + ...start, }), }, ); @@ -51,12 +64,12 @@ export async function getAvailableAppointmentsForService( : `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`; } - // await logRequestResult( - // ExternalApi.ConnectedOnline, - // ConnectedOnlineMethodName.GetAvailabilities, - // RequestStatus.Fail, - // comment, - // ); + await logRequestResult( + ExternalApi.ConnectedOnline, + ConnectedOnlineMethodName.GetAvailabilities, + RequestStatus.Fail, + comment, + ); return null; } @@ -80,157 +93,210 @@ export async function getAvailableAppointmentsForService( } export async function bookAppointment( - serviceSyncId: number, + serviceId: number, clinicId: number, appointmentUserId: number, syncUserID: number, startTime: string, - locationId = 0, comments = '', - isEarlierTimeRequested = false, - earlierTimeRequestComment = '', ) { - const supabase = await createClient(); + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + let reason = FailureReason.BOOKING_FAILED; try { const { data: { user }, } = await supabase.auth.getUser(); + logger.info( + `Booking time slot ${JSON.stringify({ serviceId, clinicId, startTime, userId: user?.id })}`, + ); + if (!user?.id) { throw new Error('User not authenticated'); } + const formattedStartTime = startTime.replace('T', ' '); + const [ { data: dbClinic, error: clinicError }, { data: dbService, error: serviceError }, + { data: account, error: accountError }, + { data: dbReservation, error: dbReservationError }, ] = await Promise.all([ supabase .schema('medreport') .from('connected_online_providers') .select('*') .eq('id', clinicId) - .limit(1), + .single(), supabase .schema('medreport') .from('connected_online_services') .select('*') - .eq('sync_id', serviceSyncId) + .eq('id', serviceId) .eq('clinic_id', clinicId) - .limit(1), + .single(), + supabase + .schema('medreport') + .from('accounts') + .select('name, last_name, personal_code, phone, email') + .eq('is_personal_account', true) + .eq('primary_owner_user_id', user.id) + .single(), + supabase + .schema('medreport') + .from('connected_online_reservation') + .select('id') + .eq('clinic_id', clinicId) + .eq('service_id', serviceId) + .eq('start_time', formattedStartTime) + .eq('user_id', user.id) + .eq('status', 'PENDING') + .single(), ]); - if (!dbClinic?.length || !dbService?.length) { - return logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.BookTime, - RequestStatus.Fail, - dbClinic?.length - ? `Could not find clinic with id ${clinicId}, error: ${JSON.stringify(clinicError)}` - : `Could not find service with sync id ${serviceSyncId} and clinic id ${clinicId}, error: ${JSON.stringify(serviceError)}`, - startTime, - serviceSyncId, - clinicId, - ); + if (!dbClinic || !dbService) { + const errorMessage = dbClinic + ? `Could not find clinic with id ${clinicId}, error: ${JSON.stringify(clinicError)}` + : `Could not find service with sync id ${serviceId} and clinic id ${clinicId}, error: ${JSON.stringify(serviceError)}`; + logger.error(errorMessage); + + throw new Error(errorMessage); + } + + if (clinicError || serviceError || accountError) { + const stringifiedErrors = JSON.stringify({ + clinicError, + serviceError, + accountError, + }); + const errorMessage = `Failed to book time, error: ${stringifiedErrors}`; + logger.error(errorMessage); + + throw new Error(errorMessage); + } + + if (!dbReservation) { + const errorMessage = `No reservation found in db with data ${JSON.stringify({ clinicId, serviceId, startTime, userId: user.id })}, got error ${JSON.stringify(dbReservationError)}`; + logger.error(errorMessage); + + throw new Error(errorMessage); } const clinic: Tables< { schema: 'medreport' }, 'connected_online_providers' - > = dbClinic![0]; + > = dbClinic; const service: Tables< { schema: 'medreport' }, 'connected_online_services' - > = dbService![0]; + > = dbService; - // TODO the dummy data needs to be replaced with real values once they're present on the user/account - const response = await axios.post( + const connectedOnlineBookingResponse = await axios.post( `${process.env.CONNECTED_ONLINE_URL!}/${ConnectedOnlineMethodName.BookTime}`, { headers: { 'Content-Type': 'application/json; charset=utf-8', }, param: JSON.stringify({ - EarlierTime: isEarlierTimeRequested, // once we have the e-shop functionality we can let the user select if she would like to be offered earlier time slots if they become available - EarlierTimeComment: earlierTimeRequestComment, ClinicID: clinic.id, ServiceID: service.id, ClinicServiceID: service.sync_id, UserID: appointmentUserId, SyncUserID: syncUserID, StartTime: startTime, - FirstName: 'Test', - LastName: 'User', - PersonalCode: '4', - Email: user.email, - Phone: 'phone', + FirstName: account.name, + LastName: account.last_name, + PersonalCode: account.personal_code, + Email: account.email ?? user.email, + Phone: account.phone, Comments: comments, - Location: locationId, - FreeCode: '', AddToBasket: false, - Key: '7T624nlu', - Lang: 'et', // update when integrated into app, if needed + Key: dbClinic.key, + Lang: 'et', }), }, ); - const responseData: BookTimeResponse = JSON.parse(response.data.d); + const connectedOnlineBookingResponseData: BookTimeResponse = JSON.parse( + connectedOnlineBookingResponse.data.d, + ); - if (responseData?.ErrorCode !== 0 || !responseData.Value) { - return logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.BookTime, - RequestStatus.Fail, - JSON.stringify(responseData), - startTime, - service.id, - clinicId, + const errorCode = connectedOnlineBookingResponseData?.ErrorCode; + if (errorCode !== 0 || !connectedOnlineBookingResponseData.Value) { + const errorMessage = `Received invalid result from external api, error: ${JSON.stringify(connectedOnlineBookingResponseData)}`; + logger.error(errorMessage); + if (process.env.SUPPORT_EMAIL) { + await sendEmailFromTemplate( + renderBookTimeFailedEmail, + { reservationId: dbReservation.id, error: errorMessage }, + process.env.SUPPORT_EMAIL, + ); + } + await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'REJECTED', + }) + .eq('id', dbReservation.id) + .throwOnError(); + + if (errorCode === 1) { + reason = FailureReason.TIME_SLOT_UNAVAILABLE; + } + + throw new Error(errorMessage); + } + + const responseParts = connectedOnlineBookingResponseData.Value.split(','); + + const { data: updatedReservation, error } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + booking_code: responseParts[1], + requires_payment: !!responseParts[0], + status: 'CONFIRMED', + }) + .eq('id', dbReservation.id) + .select('id') + .single(); + + if (error) { + throw new Error( + JSON.stringify({ connectedOnlineBookingResponseData, error }), ); } - const responseParts = responseData.Value.split(','); - - const { error } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .insert({ - booking_code: responseParts[1], - clinic_id: clinic.id, - comments, - lang: 'et', // change later, if needed - service_id: service.id, - service_user_id: appointmentUserId, - start_time: startTime, - sync_user_id: syncUserID, - requires_payment: !!responseParts[0], - user_id: user.id, - }); + logger.info( + 'Booked time, updated reservation with id ' + updatedReservation?.id, + ); await logRequestResult( ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.BookTime, RequestStatus.Success, - JSON.stringify(responseData), - startTime, + JSON.stringify(connectedOnlineBookingResponseData), + startTime.toString(), service.id, clinicId, ); - - if (error) { - throw new Error(error.message); - } - - return responseData.Value; + return { success: true }; } catch (error) { - return logRequestResult( + logger.error(`Failed to book time, error: ${JSON.stringify(error)}`); + await logRequestResult( ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.BookTime, RequestStatus.Fail, JSON.stringify(error), - startTime, - serviceSyncId, + startTime.toString(), + serviceId, clinicId, ); + return { success: false, reason }; } } @@ -270,8 +336,182 @@ export async function getConfirmedService(reservationCode: string) { ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.ConfirmedLoad, RequestStatus.Fail, - JSON.stringify(error), + error?.toString(), ); return null; } } + +export async function getAvailableTimeSlotsForDisplay( + serviceIds: number[], + locationId: number | null, + date?: Date, +): Promise { + const supabase = getSupabaseServerClient(); + + const { data: syncedServices } = await supabase + .schema('medreport') + .from('connected_online_services') + .select( + '*, providerClinic:clinic_id(*,locations:connected_online_locations(*))', + ) + .in('id', serviceIds) + .throwOnError(); + + const timeSlotPromises = []; + for (const syncedService of syncedServices) { + const timeSlotsPromise = getAvailableAppointmentsForService( + syncedService.id, + syncedService.providerClinic.key, + locationId, + date, + ); + timeSlotPromises.push(timeSlotsPromise); + } + + const timeSlots = await Promise.all(timeSlotPromises); + + const mappedTimeSlots = []; + for (const timeSlotGroup of timeSlots) { + const { data: serviceProviders } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .select( + 'name, id, jobTitleEn: job_title_en, jobTitleEt: job_title_et, jobTitleRu: job_title_ru, clinicId: clinic_id', + ) + .in( + 'clinic_id', + uniq(timeSlotGroup?.T_Booking.map(({ ClinicID }) => ClinicID)), + ) + .throwOnError(); + + const timeSlots = + timeSlotGroup?.T_Booking?.map((item) => { + return { + ...item, + serviceProvider: serviceProviders.find( + ({ id }) => id === item.UserID, + ), + syncedService: syncedServices.find( + (syncedService) => syncedService.sync_id === item.ServiceID, + ), + location: syncedServices + .find( + ({ providerClinic }) => + providerClinic.id === Number(item.ClinicID), + ) + ?.providerClinic?.locations?.find( + (location) => location.sync_id === item.LocationID, + ), + }; + }) ?? []; + mappedTimeSlots.push(...timeSlots); + } + + return { + timeSlots: mappedTimeSlots, + locations: uniqBy( + syncedServices.flatMap(({ providerClinic }) => providerClinic.locations), + 'id', + ), + }; +} + +export async function createInitialReservation( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserID: number, + startTime: Date, + medusaLineItemId: string, + locationId?: number | null, + comments = '', +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + logger.info( + 'Creating reservation' + + JSON.stringify({ serviceId, clinicId, startTime, userId }), + ); + + try { + const { data: createdReservation } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .insert({ + clinic_id: clinicId, + comments, + lang: 'et', + service_id: serviceId, + service_user_id: appointmentUserId, + start_time: startTime.toString(), + sync_user_id: syncUserID, + user_id: userId, + status: 'PENDING', + medusa_cart_line_item_id: medusaLineItemId, + location_sync_id: locationId, + }) + .select('id') + .single() + .throwOnError(); + + logger.info( + `Created reservation ${JSON.stringify({ createdReservation, userId })}`, + ); + + return createdReservation; + } catch (e) { + logger.error( + `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, + ); + await handleDeleteCartItem({ lineId: medusaLineItemId }); + throw e; + } +} + +export async function cancelReservation(medusaLineItemId: string) { + const supabase = getSupabaseServerClient(); + + return supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'CANCELLED', + }) + .eq('medusa_cart_line_item_id', medusaLineItemId) + .throwOnError(); +} + +export async function getOrderedTtoServices({ + medusaOrder, +}: { + medusaOrder: StoreOrder; +}) { + const supabase = getSupabaseServerClient(); + + const ttoReservationIds: number[] = + medusaOrder.items + ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) + .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? + []; + + const { data: orderedTtoServices } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select('*') + .in('id', ttoReservationIds) + .throwOnError(); + + return orderedTtoServices; +} diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index c33ed0d..fcb4b62 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { requireUserInServerComponent } from '../server/require-user-in-server-component'; +import { cancelReservation } from './connected-online.service'; const env = () => z @@ -26,8 +27,10 @@ const env = () => .min(1), }) .parse({ - medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + medusaBackendPublicUrl: + 'http://weebhook.site:3000' /* process.env.MEDUSA_BACKEND_PUBLIC_URL! */, + siteUrl: + 'http://weebhook.site:3000' /* process.env.NEXT_PUBLIC_SITE_URL! */, }); export async function handleAddToCart({ @@ -44,7 +47,7 @@ export async function handleAddToCart({ } const quantity = 1; - const cart = await addToCart({ + const { newCart, addedItem } = await addToCart({ variantId: selectedVariant.id, quantity, countryCode, @@ -54,18 +57,19 @@ export async function handleAddToCart({ variant_id: selectedVariant.id, operation: 'ADD_TO_CART', account_id: account.id, - cart_id: cart.id, + cart_id: newCart.id, changed_by: user.id, }); if (error) { throw new Error('Error logging cart entry: ' + error.message); } - return cart; + return { cart: newCart, addedItem }; } export async function handleDeleteCartItem({ lineId }: { lineId: string }) { await deleteLineItem(lineId); + await cancelReservation(lineId); const supabase = getSupabaseServerClient(); const cartId = await getCartId(); diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index eced8f9..0fa715c 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -152,3 +152,29 @@ export async function getAnalysisOrdersAdmin({ const orders = await query.order('created_at', { ascending: false }).throwOnError(); return orders.data; } + +export async function getTtoOrders({ + orderStatus, +}: { + orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status']; +} = {}) { + const client = getSupabaseServerClient(); + + const { + data: { user }, + } = await client.auth.getUser(); + if (!user) { + throw new Error('Unauthorized'); + } + + const query = client + .schema('medreport') + .from('connected_online_reservation') + .select('*') + .eq("user_id", user.id) + if (orderStatus) { + query.eq('status', orderStatus); + } + const orders = await query.order('created_at', { ascending: false }).throwOnError(); + return orders.data; +} \ No newline at end of file diff --git a/lib/types/connected-online.ts b/lib/types/connected-online.ts index 57e95d4..41ddcc2 100644 --- a/lib/types/connected-online.ts +++ b/lib/types/connected-online.ts @@ -10,13 +10,15 @@ export type BookTimeResponse = z.infer; export enum ConnectedOnlineMethodName { SearchLoad = 'Search_Load', + DefaultLoad = 'Default_Load', + ConfirmedCancel = 'Confirmed_Cancel', GetAvailabilities = 'GetAvailabilities', BookTime = 'BookTime', ConfirmedLoad = 'Confirmed_Load', } export const AvailableAppointmentTBookingSchema = z.object({ - ClinicID: z.string(), + ClinicID: z.number(), LocationID: z.number(), UserID: z.number(), SyncUserID: z.number(), @@ -225,6 +227,18 @@ export const ConfirmedLoadResponseSchema = z.object({ }); export type ConfirmedLoadResponse = z.infer; +export type P_JobTitleTranslation = { + ID: number; + SyncID: number; + TextEN: string; + TextET: string; + TextFI: string; + TextRU: string; + TextLT: string; + ClinicID: number; + Deleted: number; +}; + export interface ISearchLoadResponse { Value: string; Data: { @@ -232,9 +246,11 @@ export interface ISearchLoadResponse { ID: number; Name: string; OnlineCanSelectWorker: boolean; + Address: string; Email: string | null; PersonalCodeRequired: boolean; Phone: string | null; + Key: string; }[]; T_Service: { ID: number; @@ -253,7 +269,14 @@ export interface ISearchLoadResponse { RequiresPayment: boolean; SyncID: string; }[]; + T_Doctor: TDoctor[]; + P_JobTitleTranslations: P_JobTitleTranslation[]; }; ErrorCode: number; ErrorMessage: string; } + +export enum FailureReason { + BOOKING_FAILED = 'BOOKING_FAILED', + TIME_SLOT_UNAVAILABLE = 'TIME_SLOT_UNAVAILABLE', +} diff --git a/lib/utils.ts b/lib/utils.ts index 233042a..f03f520 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,7 +20,7 @@ export function toTitleCase(str?: string) { ?.toLowerCase() .replace(/[^-'’\s]+/g, (match) => match.replace(/^./, (first) => first.toUpperCase()), - ) ?? "" + ) ?? '' ); } @@ -145,6 +145,13 @@ export default class PersonalCode { gender, dob: parsed.getBirthday(), age: parsed.getAge(), - } + }; } } + +export const findProductTypeIdByHandle = ( + productTypes: { metadata?: Record | null; id: string }[], + handle: string, +) => { + return productTypes.find(({ metadata }) => metadata?.handle === handle)?.id; +}; diff --git a/packages/email-templates/src/emails/book-time-failed.email.tsx b/packages/email-templates/src/emails/book-time-failed.email.tsx new file mode 100644 index 0000000..7c4cb93 --- /dev/null +++ b/packages/email-templates/src/emails/book-time-failed.email.tsx @@ -0,0 +1,61 @@ +import { + Body, + Head, + Html, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; + +export async function renderBookTimeFailedEmail({ + reservationId, + error, +}: { + reservationId: number; + error: string; +}) { + const subject = 'Aja broneerimine ei õnnestunud'; + + const html = await render( + + + + + + {subject} + + + + + + + {subject} + + + Tere + + + + Broneeringu {reservationId} Connected Online'i saatmine ei + õnnestunud, kliendile tuleb teha tagasimakse. + + Saadud error: {error} + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index cae4d3f..227390a 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -10,3 +10,4 @@ export * from './emails/all-results-received.email'; export * from './emails/order-processing.email'; export * from './emails/patient-first-results-received.email'; export * from './emails/patient-full-results-received.email'; +export * from './emails/book-time-failed.email' diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 0bb1cfd..5a0cd84 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -15,6 +15,7 @@ import { import { getRegion } from "./regions"; import { sdk } from "@lib/config"; import { retrieveOrder } from "./orders"; +import { completeTtoCart } from "../../../../../../lib/services/connected-online.service"; /** * Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies. @@ -89,7 +90,10 @@ export async function getOrSetCart(countryCode: string) { export async function updateCart( { id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }, - { onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} }, + { onSuccess, onError }: { onSuccess: () => void; onError: () => void } = { + onSuccess: () => {}, + onError: () => {}, + } ) { const cartId = id || (await getCartId()); @@ -163,7 +167,12 @@ export async function addToCart({ }) .catch(medusaError); - return cart; + const newCart = await getOrSetCart(countryCode); + const addedItem = newCart.items?.filter( + (item) => !cart.items?.some((oldCartItem) => oldCartItem.id === item.id) + )?.[0]; + + return { newCart, addedItem }; } export async function updateLineItem({ @@ -268,7 +277,10 @@ export async function initiatePaymentSession( export async function applyPromotions( codes: string[], - { onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} }, + { onSuccess, onError }: { onSuccess: () => void; onError: () => void } = { + onSuccess: () => {}, + onError: () => {}, + } ) { const cartId = await getCartId(); @@ -410,7 +422,10 @@ export async function setAddresses(currentState: unknown, formData: FormData) { * @param cartId - optional - The ID of the cart to place an order for. * @returns The cart object if the order was successful, or null if not. */ -export async function placeOrder(cartId?: string, options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true }) { +export async function placeOrder( + cartId?: string, + options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true } +) { const id = cartId || (await getCartId()); if (!id) { diff --git a/packages/features/medusa-storefront/src/lib/data/categories.ts b/packages/features/medusa-storefront/src/lib/data/categories.ts index b4db69d..8e561fa 100644 --- a/packages/features/medusa-storefront/src/lib/data/categories.ts +++ b/packages/features/medusa-storefront/src/lib/data/categories.ts @@ -20,7 +20,6 @@ export const listCategories = async (query?: Record) => { ...query, }, next, - cache: "force-cache", } ) .then(({ product_categories }) => product_categories); @@ -56,7 +55,6 @@ export const getProductCategories = async ({ limit, }, next, - //cache: "force-cache", } ); }; diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index c9b8d7e..821ebd2 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -4,8 +4,8 @@ import { cookies } from 'next/headers'; import { createServerClient } from '@supabase/ssr'; -import { Database } from '../database.types'; import { getSupabaseClientKeys } from '../get-supabase-client-keys'; +import { Database } from '../database.types'; /** * @name getSupabaseServerClient diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index a09d6b8..64fa0c2 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -198,6 +198,7 @@ export type Database = { action: string changed_by: string created_at: string + extra_data: Json | null id: number } Insert: { @@ -205,6 +206,7 @@ export type Database = { action: string changed_by: string created_at?: string + extra_data?: Json | null id?: number } Update: { @@ -212,6 +214,7 @@ export type Database = { action?: string changed_by?: string created_at?: string + extra_data?: Json | null id?: number } Relationships: [] @@ -221,7 +224,6 @@ export type Database = { comment: string | null created_at: string id: number - personal_code: number | null request_api: string request_api_method: string requested_end_date: string | null @@ -229,12 +231,12 @@ export type Database = { service_id: number | null service_provider_id: number | null status: Database["audit"]["Enums"]["request_status"] + user_id: string | null } Insert: { comment?: string | null created_at?: string id?: number - personal_code?: number | null request_api: string request_api_method: string requested_end_date?: string | null @@ -242,12 +244,12 @@ export type Database = { service_id?: number | null service_provider_id?: number | null status: Database["audit"]["Enums"]["request_status"] + user_id?: string | null } Update: { comment?: string | null created_at?: string id?: number - personal_code?: number | null request_api?: string request_api_method?: string requested_end_date?: string | null @@ -255,6 +257,7 @@ export type Database = { service_id?: number | null service_provider_id?: number | null status?: Database["audit"]["Enums"]["request_status"] + user_id?: string | null } Relationships: [] } @@ -990,32 +993,76 @@ export type Database = { } Relationships: [] } + connected_online_locations: { + Row: { + address: string | null + clinic_id: number + created_at: string + id: number + name: string + sync_id: number + updated_at: string | null + } + Insert: { + address?: string | null + clinic_id: number + created_at?: string + id?: number + name: string + sync_id: number + updated_at?: string | null + } + Update: { + address?: string | null + clinic_id?: number + created_at?: string + id?: number + name?: string + sync_id?: number + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "connected_online_locations_clinic_id_fkey" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + ] + } connected_online_providers: { Row: { + address: string can_select_worker: boolean created_at: string email: string | null id: number + key: string name: string personal_code_required: boolean phone_number: string | null updated_at: string | null } Insert: { + address?: string can_select_worker: boolean created_at?: string email?: string | null id: number + key: string name: string personal_code_required: boolean phone_number?: string | null updated_at?: string | null } Update: { + address?: string can_select_worker?: boolean created_at?: string email?: string | null id?: number + key?: string name?: string personal_code_required?: boolean phone_number?: string | null @@ -1025,55 +1072,117 @@ export type Database = { } connected_online_reservation: { Row: { - booking_code: string + booking_code: string | null clinic_id: number comments: string | null created_at: string discount_code: string | null id: number lang: string - requires_payment: boolean + location_sync_id: number | null + medusa_cart_line_item_id: string | null + requires_payment: boolean | null service_id: number - service_user_id: number | null + service_user_id: number start_time: string + status: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id: number updated_at: string | null user_id: string } Insert: { - booking_code: string + booking_code?: string | null clinic_id: number comments?: string | null created_at?: string discount_code?: string | null id?: number lang: string - requires_payment: boolean + location_sync_id?: number | null + medusa_cart_line_item_id?: string | null + requires_payment?: boolean | null service_id: number - service_user_id?: number | null + service_user_id: number start_time: string + status: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id: number updated_at?: string | null user_id: string } Update: { - booking_code?: string + booking_code?: string | null clinic_id?: number comments?: string | null created_at?: string discount_code?: string | null id?: number lang?: string - requires_payment?: boolean + location_sync_id?: number | null + medusa_cart_line_item_id?: string | null + requires_payment?: boolean | null service_id?: number - service_user_id?: number | null + service_user_id?: number start_time?: string + status?: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id?: number updated_at?: string | null user_id?: string } Relationships: [] } + connected_online_service_providers: { + Row: { + clinic_id: number + created_at: string + id: number + is_deleted: boolean | null + job_title_en: string | null + job_title_et: string | null + job_title_id: number | null + job_title_ru: string | null + name: string + prefix: string | null + spoken_languages: string[] | null + updated_at: string | null + } + Insert: { + clinic_id: number + created_at?: string + id: number + is_deleted?: boolean | null + job_title_en?: string | null + job_title_et?: string | null + job_title_id?: number | null + job_title_ru?: string | null + name: string + prefix?: string | null + spoken_languages?: string[] | null + updated_at?: string | null + } + Update: { + clinic_id?: number + created_at?: string + id?: number + is_deleted?: boolean | null + job_title_en?: string | null + job_title_et?: string | null + job_title_id?: number | null + job_title_ru?: string | null + name?: string + prefix?: string | null + spoken_languages?: string[] | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "connected_online_service_providers_clinic_id_fkey" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + ] + } connected_online_services: { Row: { clinic_id: number @@ -1091,7 +1200,7 @@ export type Database = { price: number price_periods: string | null requires_payment: boolean - sync_id: string + sync_id: number updated_at: string | null } Insert: { @@ -1110,7 +1219,7 @@ export type Database = { price: number price_periods?: string | null requires_payment: boolean - sync_id: string + sync_id: number updated_at?: string | null } Update: { @@ -1129,7 +1238,7 @@ export type Database = { price?: number price_periods?: string | null requires_payment?: boolean - sync_id?: string + sync_id?: number updated_at?: string | null } Relationships: [ @@ -1150,7 +1259,7 @@ export type Database = { doctor_user_id: string | null id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at: string + updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1162,7 +1271,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1174,7 +1283,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1259,23 +1368,36 @@ export type Database = { } medipost_actions: { Row: { - created_at: string - id: number action: string - xml: string + created_at: string | null has_analysis_results: boolean - medusa_order_id: string - response_xml: string has_error: boolean + id: string + medusa_order_id: string | null + response_xml: string | null + xml: string | null } Insert: { action: string - xml: string - has_analysis_results: boolean - medusa_order_id: string - response_xml: string - has_error: boolean + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medusa_order_id?: string | null + response_xml?: string | null + xml?: string | null } + Update: { + action?: string + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medusa_order_id?: string | null + response_xml?: string | null + xml?: string | null + } + Relationships: [] } medreport_product_groups: { Row: { @@ -1944,6 +2066,13 @@ export type Database = { personal_code: string }[] } + get_latest_medipost_dispatch_state_for_order: { + Args: { medusa_order_id: string } + Returns: { + action_date: string + has_success: boolean + }[] + } get_medipost_dispatch_tries: { Args: { p_medusa_order_id: string } Returns: number @@ -2036,9 +2165,9 @@ export type Database = { Args: { account_id: string; user_id: string } Returns: boolean } - medipost_retry_dispatch: { - Args: { order_id: string } - Returns: Json + order_has_medipost_dispatch_error: { + Args: { medusa_order_id: string } + Returns: boolean } revoke_nonce: { Args: { p_id: string; p_reason?: string } @@ -2065,16 +2194,26 @@ export type Database = { Returns: undefined } update_account: { - Args: { - p_city: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - p_email: string - } + Args: + | { + p_city: string + p_email: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } + | { + p_city: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } Returns: undefined } update_analysis_order_status: { @@ -2182,6 +2321,11 @@ export type Database = { | "invites.manage" application_role: "user" | "doctor" | "super_admin" billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio" + connected_online_order_status: + | "PENDING" + | "CONFIRMED" + | "REJECTED" + | "CANCELLED" locale: "en" | "et" | "ru" notification_channel: "in_app" | "email" notification_type: "info" | "warning" | "error" @@ -8099,6 +8243,12 @@ export const Constants = { ], application_role: ["user", "doctor", "super_admin"], billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"], + connected_online_order_status: [ + "PENDING", + "CONFIRMED", + "REJECTED", + "CANCELLED", + ], locale: ["en", "et", "ru"], notification_channel: ["in_app", "email"], notification_type: ["info", "warning", "error"], diff --git a/packages/ui/src/shadcn/calendar.tsx b/packages/ui/src/shadcn/calendar.tsx index 030dd5a..138278d 100644 --- a/packages/ui/src/shadcn/calendar.tsx +++ b/packages/ui/src/shadcn/calendar.tsx @@ -38,7 +38,7 @@ function Calendar({ head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]', row: 'flex w-full mt-2', - cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20', + cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-md focus-within:relative focus-within:z-20', day: cn( buttonVariants({ variant: 'ghost' }), 'h-9 w-9 p-0 font-normal aria-selected:opacity-100', diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index f4ef7c2..8a38d9f 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -4,5 +4,7 @@ "analysisPackages": { "title": "Analysis packages", "description": "Get to know the personal analysis packages and order" - } + }, + "noCategories": "List of services not found, try again later", + "noResults": "No availabilities found for selected dates" } \ No newline at end of file diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json index 3410d59..bc2d2cd 100644 --- a/public/locales/et/booking.json +++ b/public/locales/et/booking.json @@ -5,5 +5,13 @@ "title": "Analüüside paketid", "description": "Tutvu personaalsete analüüsi pakettidega ja telli" }, - "noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti" -} + "noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti", + "noResults": "Valitud kuupäevadel ei ole vabu aegu", + "services": "Teenused", + "locations": "Asutused", + "showAll": "Kuva kõik", + "showAllLocations": "Näita kõiki asutusi", + "bookTimeSuccess": "Aeg valitud", + "bookTimeError": "Aega ei õnnestunud valida", + "bookTimeLoading": "Aega valitakse..." +} \ No newline at end of file diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index 36b69d0..2b2bfa5 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -14,7 +14,9 @@ "goToDashboard": "Jätkan", "error": { "title": "Midagi läks valesti", - "description": "Palun proovi hiljem uuesti." + "description": "Palun proovi hiljem uuesti.", + "BOOKING_FAILED": "Teenuse tõrge, proovi hiljem uuesti.", + "TIME_SLOT_UNAVAILABLE": "Valitud aeg ei ole saadaval." }, "timeLeft": "Aega jäänud {{timeLeft}}", "timeoutTitle": "Broneering aegus", diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 96f3572..7611586 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -147,5 +147,6 @@ "language": "Keel", "yes": "Jah", "no": "Ei", - "preferNotToAnswer": "Eelistan mitte vastata" + "preferNotToAnswer": "Eelistan mitte vastata", + "book": "Broneeri" } \ No newline at end of file diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json index 822c6b1..e266935 100644 --- a/public/locales/et/orders.json +++ b/public/locales/et/orders.json @@ -4,17 +4,33 @@ "noOrders": "Tellimusi ei leitud", "table": { "analysisPackage": "Analüüsi pakett", + "ttoService": "Broneering", "otherOrders": "Tellimus", "createdAt": "Tellitud", "status": "Olek" }, "status": { "QUEUED": "Esitatud", - "PROCESSING": "Synlabile edastatud", + "PROCESSING": "Edastatud", "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", - "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet", + "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes", "COMPLETED": "Kinnitatud", "REJECTED": "Tagastatud", - "CANCELLED": "Tühistatud" + "CANCELLED": "Tühistatud", + "analysisOrder": { + "QUEUED": "Esitatud", + "PROCESSING": "Synlabile edastatud", + "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", + "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet", + "COMPLETED": "Kinnitatud", + "REJECTED": "Tagastatud", + "CANCELLED": "Tühistatud" + }, + "ttoService": { + "PENDING": "Alustatud", + "CONFIRMED": "Kinnitatud", + "REJECTED": "Tagasi lükatud", + "CANCELLED": "Tühistatud" + } } } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index 042a4b6..df29269 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -5,5 +5,6 @@ "title": "Пакеты анализов", "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, - "noCategories": "Список услуг не найден, попробуйте позже" -} + "noCategories": "Список услуг не найден, попробуйте позже", + "noResults": "Для выбранных дат доступных вариантов не найдено" +} \ No newline at end of file diff --git a/supabase/migrations/20250908092531_add_clinic_key.sql b/supabase/migrations/20250908092531_add_clinic_key.sql new file mode 100644 index 0000000..fa01010 --- /dev/null +++ b/supabase/migrations/20250908092531_add_clinic_key.sql @@ -0,0 +1,11 @@ +ALTER TABLE medreport.connected_online_providers ADD COLUMN key text not null DEFAULT ''; -- avoid conflict with already existing data, will be filled on next sync +ALTER TABLE medreport.connected_online_providers +ALTER key DROP DEFAULT; + +ALTER TABLE medreport.connected_online_providers ADD COLUMN address text not null DEFAULT ''; +ALTER TABLE medreport.connected_online_providers +ALTER key DROP DEFAULT; + +ALTER TABLE medreport.connected_online_services +ALTER COLUMN sync_id TYPE bigint +USING sync_id::bigint; diff --git a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql new file mode 100644 index 0000000..85dd813 --- /dev/null +++ b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql @@ -0,0 +1,60 @@ +create table "medreport"."connected_online_service_providers" ( + "id" bigint not null primary key, + "name" text not null, + "spoken_languages" text[], + "prefix" text, + "job_title_et" text, + "job_title_en" text, + "job_title_ru" text, + "clinic_id" bigint not null REFERENCES medreport.connected_online_providers(id), + "job_title_id" bigint, + "is_deleted" boolean, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp without time zone default now() +); + +ALTER TABLE audit.request_entries +DROP COLUMN personal_code; + +ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid() references auth.users(id); + +create policy "insert_own" +on "audit"."request_entries" +as permissive +for insert +to authenticated +with check ((( SELECT auth.uid() AS uid) = user_id)); + +alter table "medreport"."connected_online_service_providers" enable row level security; + +create policy "service_role_all" +on "medreport"."connected_online_service_providers" +as permissive +for all +to service_role +using (true); + + +grant delete on table "medreport"."connected_online_service_providers" to "service_role"; + +grant insert on table "medreport"."connected_online_service_providers" to "service_role"; + +grant references on table "medreport"."connected_online_service_providers" to "service_role"; + +grant select on table "medreport"."connected_online_service_providers" to "service_role"; + +grant trigger on table "medreport"."connected_online_service_providers" to "service_role"; + +grant truncate on table "medreport"."connected_online_service_providers" to "service_role"; + +grant update on table "medreport"."connected_online_service_providers" to "service_role"; + +grant select on table "medreport"."connected_online_service_providers" to "authenticated"; + + +create policy "authenticated_select" +on "medreport"."connected_online_service_providers" +as permissive +for select +to authenticated +using (true); diff --git a/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql b/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql new file mode 100644 index 0000000..ebb59b7 --- /dev/null +++ b/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql @@ -0,0 +1,14 @@ +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN medusa_cart_line_item_id TEXT references public.cart_line_item(id); + +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN location_sync_id bigint; + +create type medreport.connected_online_order_status as enum ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELLED'); + +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN status medreport.connected_online_order_status not null; + +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN booking_code DROP NOT NULL; +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN requires_payment DROP NOT NULL; +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN service_user_id SET NOT NULL; diff --git a/supabase/migrations/20250915101146_add_connected_online_locations_table.sql b/supabase/migrations/20250915101146_add_connected_online_locations_table.sql new file mode 100644 index 0000000..fa8d0dc --- /dev/null +++ b/supabase/migrations/20250915101146_add_connected_online_locations_table.sql @@ -0,0 +1,43 @@ +create table "medreport"."connected_online_locations" ( + "id" bigint generated by default as identity not null, + "sync_id" bigint not null, + "name" text not null, + "address" text, + "clinic_id" bigint not null REFERENCES medreport.connected_online_providers(id), + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp without time zone default now() +); + +alter table "medreport"."connected_online_locations" enable row level security; + +create policy "service_role_all" +on "medreport"."connected_online_locations" +as permissive +for all +to service_role +using (true); + + +grant delete on table "medreport"."connected_online_locations" to "service_role"; + +grant insert on table "medreport"."connected_online_locations" to "service_role"; + +grant references on table "medreport"."connected_online_locations" to "service_role"; + +grant select on table "medreport"."connected_online_locations" to "service_role"; + +grant trigger on table "medreport"."connected_online_locations" to "service_role"; + +grant truncate on table "medreport"."connected_online_locations" to "service_role"; + +grant update on table "medreport"."connected_online_locations" to "service_role"; + +grant select on table "medreport"."connected_online_locations" to "authenticated"; + + +create policy "authenticated_select" +on "medreport"."connected_online_locations" +as permissive +for select +to authenticated +using (true); From 4bd88f1b4eaf3aa13be542d994d22ff9238d6fe9 Mon Sep 17 00:00:00 2001 From: Helena Date: Wed, 17 Sep 2025 18:23:25 +0300 Subject: [PATCH 03/47] clean up --- .../(dashboard)/booking/[handle]/page.tsx | 2 +- .../_components/booking/booking.context.ts | 8 +++---- .../_components/booking/booking.provider.tsx | 9 ++++--- app/home/(user)/_lib/server/actions.ts | 24 ++----------------- .../medusa-storefront/src/lib/data/cart.ts | 1 - 5 files changed, 13 insertions(+), 31 deletions(-) diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index 3d9c29e..395b590 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -7,7 +7,7 @@ import { Trans } from '@kit/ui/trans'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import BookingContainer from '../../../_components/booking/booking-container'; +import BookingContainer from '~/home/(user)/_components/booking/booking-container'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index a8b47a4..ebff73d 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -55,12 +55,12 @@ const BookingContext = createContext<{ selectedService: StoreProduct | null; locations: Location[] | null; selectedLocationId: number | null; - selectedDate?: Date; + selectedDate?: Date | null; isLoadingTimeSlots?: boolean; - setSelectedService: (selectedService?: StoreProduct) => void; + setSelectedService: (selectedService: StoreProduct | null) => void; setSelectedLocationId: (selectedLocationId: number | null) => void; - updateTimeSlots: (serviceId: number) => Promise; - setSelectedDate: (selectedDate?: Date) => void; + updateTimeSlots: (serviceIds: number[]) => Promise; + setSelectedDate: (selectedDate: Date | null) => void; }>({ timeSlots: null, selectedService: null, diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index bf6e1f7..0bf823b 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,7 +27,7 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); @@ -49,9 +49,12 @@ export const BookingProvider: React.FC<{ const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); try { - const response = await getAvailableTimeSlotsForDisplay(serviceIds, selectedLocationId); + const response = await getAvailableTimeSlotsForDisplay( + serviceIds, + selectedLocationId, + ); setTimeSlots(response.timeSlots); - setLocations(response.locations) + setLocations(response.locations); } catch (error) { setTimeSlots(null); } finally { diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index 7543d34..dfb6f88 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -2,32 +2,12 @@ import { StoreProductVariant } from '@medusajs/types'; +import { updateLineItem } from "@lib/data/cart"; import { - bookAppointment, - createInitialReservation, + createInitialReservation } from '~/lib/services/connected-online.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service'; -import { updateLineItem } from '../../../../../packages/features/medusa-storefront/src/lib/data'; - -export async function bookTimeAction( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserId: number, - startTime: Date, - comments?: string, -) { - return bookAppointment( - serviceId, - clinicId, - appointmentUserId, - syncUserId, - startTime, - comments, - ); -} - export async function createInitialReservationAction( selectedVariant: Pick, countryCode: string, diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 5a0cd84..b56ba48 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -15,7 +15,6 @@ import { import { getRegion } from "./regions"; import { sdk } from "@lib/config"; import { retrieveOrder } from "./orders"; -import { completeTtoCart } from "../../../../../../lib/services/connected-online.service"; /** * Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies. From db2ccd0f578f887ee4e32e77fdd3b2b3ceccc0bb Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 09:45:09 +0300 Subject: [PATCH 04/47] log page views --- .../(dashboard)/booking/[handle]/page.tsx | 21 +++++++++++++++++-- lib/services/audit/pageView.service.ts | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index 395b590..fa25357 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -1,13 +1,17 @@ +import { redirect } from 'next/navigation'; + import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header'; import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; +import { pathsConfig } from '@kit/shared/config'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; import { Trans } from '@kit/ui/trans'; +import BookingContainer from '~/home/(user)/_components/booking/booking-container'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; - -import BookingContainer from '~/home/(user)/_components/booking/booking-container'; +import { PageViewAction, createPageViewLog } from '~/lib/services/audit/pageView.service'; +import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -25,11 +29,24 @@ async function BookingHandlePage({ }) { const { handle } = await params; const { category } = await loadCategory({ handle }); + const { account } = await loadCurrentUserAccount(); if (!category) { return
Category not found
; } + if (!account) { + return redirect(pathsConfig.auth.signIn); + } + + await createPageViewLog({ + accountId: account.id, + action: PageViewAction.VIEW_TTO_SERVICE_BOOKING, + extraData: { + handle, + }, + }); + return ( <> Date: Thu, 18 Sep 2025 09:48:26 +0300 Subject: [PATCH 05/47] fix broken import --- app/home/(user)/(dashboard)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index de4d375..d7de100 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -1,7 +1,6 @@ import { redirect } from 'next/navigation'; import { toTitleCase } from '@/lib/utils'; -import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { PageBody, PageHeader } from '@kit/ui/page'; @@ -13,6 +12,7 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import Dashboard from '../_components/dashboard'; import DashboardCards from '../_components/dashboard-cards'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); From 3f3fbad5561b35af110b4225b941154dac19c36f Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:17:07 +0300 Subject: [PATCH 06/47] mobile improvements --- .../_components/booking/booking-container.tsx | 41 ++----------------- .../_components/booking/booking.context.ts | 4 +- .../_components/booking/booking.provider.tsx | 2 +- .../_components/booking/location-selector.tsx | 7 +--- .../_components/booking/service-selector.tsx | 2 +- .../(user)/_components/booking/time-slots.tsx | 4 +- packages/ui/src/shadcn/calendar.tsx | 4 +- 7 files changed, 12 insertions(+), 52 deletions(-) diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index d7ca8cc..77caa97 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,51 +1,16 @@ 'use client'; -import { isBefore, isSameDay } from 'date-fns'; -import { uniq } from 'lodash'; - -import { Calendar } from '@kit/ui/shadcn/calendar'; -import { Card } from '@kit/ui/shadcn/card'; - import { ServiceCategory } from '../service-categories'; -import { BookingProvider, useBooking } from './booking.provider'; +import BookingCalendar from './booking-calendar'; +import { BookingProvider } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; -const BookingCalendar = () => { - const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = - useBooking(); - const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); - - return ( - - { - const today = new Date(); - today.setHours(0, 0, 0, 0); - return ( - isBefore(date, today) || - !availableDates.some((dateWithBooking) => - isSameDay(date, dateWithBooking), - ) - ); - }} - className="rounded-md border" - {...(isLoadingTimeSlots && { - className: 'rounded-md border opacity-50 pointer-events-none', - })} - /> - - ); -}; - const BookingContainer = ({ category }: { category: ServiceCategory }) => { return ( -
+
diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index ebff73d..ce59dba 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -55,12 +55,12 @@ const BookingContext = createContext<{ selectedService: StoreProduct | null; locations: Location[] | null; selectedLocationId: number | null; - selectedDate?: Date | null; + selectedDate?: Date; isLoadingTimeSlots?: boolean; setSelectedService: (selectedService: StoreProduct | null) => void; setSelectedLocationId: (selectedLocationId: number | null) => void; updateTimeSlots: (serviceIds: number[]) => Promise; - setSelectedDate: (selectedDate: Date | null) => void; + setSelectedDate: (selectedDate?: Date) => void; }>({ timeSlots: null, selectedService: null, diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 0bf823b..fcde360 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,7 +27,7 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx index f792fa5..4cb02aa 100644 --- a/app/home/(user)/_components/booking/location-selector.tsx +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -9,12 +9,7 @@ import { useBooking } from './booking.provider'; const LocationSelector = () => { const { t } = useTranslation(); - const { - selectedService, - selectedLocationId, - setSelectedLocationId, - locations, - } = useBooking(); + const { selectedLocationId, setSelectedLocationId, locations } = useBooking(); const onLocationSelect = (locationId: number | string | null) => { if (locationId === 'all') return setSelectedLocationId(null); diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 0c60a3e..42540d1 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -22,7 +22,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); - setSelectedService(product); + setSelectedService(product ?? null); setCollapsed(false); }; diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index d74befc..587ee95 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -156,7 +156,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { booking.selectedService?.variants?.[0]?.calculated_price ?.calculated_amount; return ( - +
{formatDateAndTime(timeSlot.StartTime.toString())}
@@ -181,7 +181,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { {timeSlot.location?.address}
-
+
{formatCurrency({ currencyCode: 'EUR', diff --git a/packages/ui/src/shadcn/calendar.tsx b/packages/ui/src/shadcn/calendar.tsx index 138278d..967afa2 100644 --- a/packages/ui/src/shadcn/calendar.tsx +++ b/packages/ui/src/shadcn/calendar.tsx @@ -34,10 +34,10 @@ function Calendar({ nav_button_previous: 'absolute left-1', nav_button_next: 'absolute right-1', table: 'w-full border-collapse space-y-1', - head_row: 'flex', + head_row: 'flex justify-evenly', head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]', - row: 'flex w-full mt-2', + row: 'flex w-full mt-2 justify-evenly', cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-md focus-within:relative focus-within:z-20', day: cn( buttonVariants({ variant: 'ghost' }), From 71f3aed875ede9e3cbe430007b0cc57208f47b0a Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:17:24 +0300 Subject: [PATCH 07/47] move booking calendar to separate file --- .../_components/booking/booking-calendar.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/home/(user)/_components/booking/booking-calendar.tsx diff --git a/app/home/(user)/_components/booking/booking-calendar.tsx b/app/home/(user)/_components/booking/booking-calendar.tsx new file mode 100644 index 0000000..300add5 --- /dev/null +++ b/app/home/(user)/_components/booking/booking-calendar.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { isBefore, isSameDay } from 'date-fns'; +import { uniq } from 'lodash'; + +import { Calendar } from '@kit/ui/shadcn/calendar'; +import { Card } from '@kit/ui/shadcn/card'; + +import { useBooking } from './booking.provider'; + +export default function BookingCalendar() { + const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = + useBooking(); + const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); + + return ( + + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return ( + isBefore(date, today) || + !availableDates.some((dateWithBooking) => + isSameDay(date, dateWithBooking), + ) + ); + }} + className="rounded-md border" + {...(isLoadingTimeSlots && { + className: 'rounded-md border opacity-50 pointer-events-none', + })} + /> + + ); +} From 2f470d3531633b202805bc80c7427e5071941a74 Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:24:47 +0300 Subject: [PATCH 08/47] revert some whitespace changes for better readability --- .../cart/montonio-callback/actions.ts | 66 ++++++------------- 1 file changed, 20 insertions(+), 46 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index c7f67c4..ff97af4 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -124,38 +124,27 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { 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 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, - ); + 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 ?? '', - } + 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, - analysisItemsOrder: - Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', - })) - : null, }; } @@ -196,22 +185,14 @@ export async function processMontonioCallback(orderToken: string) { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { - revalidateCacheTags: false, - }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ - medusaOrder, - }); + const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); const orderContainsSynlabItems = !!orderedAnalysisElements?.length; try { - const existingAnalysisOrder = await getAnalysisOrder({ - medusaOrderId: medusaOrder.id, - }); - console.info( - `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, - ); + 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 @@ -227,8 +208,7 @@ export async function processMontonioCallback(orderToken: string) { const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = - orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); let bookServiceResults: { @@ -251,20 +231,14 @@ export async function processMontonioCallback(orderToken: string) { if (email) { if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ - account, - email, - 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`, - ); + console.warn(`Order has analysis items, but no email template exists yet`); } else { console.info(`Order has no analysis items, skipping email.`); } From f0fbca832d8225203003d018b076521bb639b6a1 Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 16:40:25 +0300 Subject: [PATCH 09/47] use cn --- app/home/(user)/_components/booking/booking-calendar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/home/(user)/_components/booking/booking-calendar.tsx b/app/home/(user)/_components/booking/booking-calendar.tsx index 300add5..f8afb0b 100644 --- a/app/home/(user)/_components/booking/booking-calendar.tsx +++ b/app/home/(user)/_components/booking/booking-calendar.tsx @@ -5,6 +5,7 @@ import { uniq } from 'lodash'; import { Calendar } from '@kit/ui/shadcn/calendar'; import { Card } from '@kit/ui/shadcn/card'; +import { cn } from '@kit/ui/utils'; import { useBooking } from './booking.provider'; @@ -29,9 +30,9 @@ export default function BookingCalendar() { ) ); }} - className="rounded-md border" - {...(isLoadingTimeSlots && { - className: 'rounded-md border opacity-50 pointer-events-none', + className={cn('rounded-md border', { + 'pointer-events-none rounded-md border opacity-50': + isLoadingTimeSlots, })} /> From 3c272505d6ad906689f2074c156cf268840ca622 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 10:18:18 +0300 Subject: [PATCH 10/47] various minor changes --- app/home/(user)/_components/booking/booking.provider.tsx | 6 +++--- app/home/(user)/_components/orders/order-items-table.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index fcde360..3f3cb18 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,10 +27,10 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); - const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); + const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); useEffect(() => { let metadataServiceIds = []; @@ -39,7 +39,7 @@ export const BookingProvider: React.FC<{ selectedService?.metadata?.serviceIds as string, ); } catch (e) { - return; + console.error(e); } if (metadataServiceIds.length) { updateTimeSlots(metadataServiceIds); diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index 6d621e6..39ec1de 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -59,7 +59,7 @@ export default function OrderItemsTable({ - + {isAnalysisOrder && } From b59148630ad6320dc4fe116f91215b4ba1cf0329 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 16:23:19 +0300 Subject: [PATCH 11/47] add cart functionality for tto services --- app/api/job/handler/sync-connected-online.ts | 3 +- .../cart/montonio-callback/actions.ts | 2 +- app/home/(user)/(dashboard)/cart/page.tsx | 19 +- .../_components/booking/booking-container.tsx | 34 +- .../_components/booking/booking.provider.tsx | 9 +- .../_components/booking/service-selector.tsx | 30 +- .../(user)/_components/booking/time-slots.tsx | 74 +++- .../_components/cart/cart-service-item.tsx | 141 ++++++++ .../_components/cart/cart-service-items.tsx | 72 ++++ app/home/(user)/_components/cart/index.tsx | 124 ++++--- app/home/(user)/_components/cart/types.ts | 24 +- app/home/(user)/_lib/server/actions.ts | 6 +- lib/services/connected-online.service.ts | 109 +----- lib/services/medusaCart.service.ts | 46 ++- lib/services/order.service.ts | 8 + lib/services/reservation.service.ts | 333 ++++++++++++++++++ lib/types/reservation.ts | 35 ++ packages/supabase/src/database.types.ts | 17 +- public/locales/en/booking.json | 6 +- public/locales/en/cart.json | 8 +- public/locales/et/booking.json | 5 +- public/locales/et/cart.json | 8 +- public/locales/et/common.json | 3 +- public/locales/ru/booking.json | 6 +- public/locales/ru/cart.json | 8 +- ...9121028_add_references_to_reservations.sql | 12 + 26 files changed, 921 insertions(+), 221 deletions(-) create mode 100644 app/home/(user)/_components/cart/cart-service-item.tsx create mode 100644 app/home/(user)/_components/cart/cart-service-items.tsx create mode 100644 lib/services/reservation.service.ts create mode 100644 lib/types/reservation.ts create mode 100644 supabase/migrations/20250919121028_add_references_to_reservations.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 2cc0447..07b7435 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,6 +1,5 @@ import axios from 'axios'; -import { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { logSyncResult } from '~/lib/services/audit.service'; @@ -131,7 +130,7 @@ export default async function syncConnectedOnline() { return { id: service.ID, clinic_id: service.ClinicID, - sync_id: service.SyncID, + sync_id: Number(service.SyncID), name: service.Name, description: service.Description || null, price: service.Price, diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index ff97af4..806588e 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -12,13 +12,13 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client' import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { bookAppointment, - getOrderedTtoServices, } from '~/lib/services/connected-online.service'; import { FailureReason } from '~/lib/types/connected-online'; import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; import { AccountWithParams } from '@kit/accounts/types/accounts'; +import { getOrderedTtoServices } from '~/lib/services/reservation.service'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 20efdce..3016fe8 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -8,10 +8,12 @@ import { listProductTypes } from '@lib/data/products'; import { Trans } from '@kit/ui/trans'; import { withI18n } from '~/lib/i18n/with-i18n'; - import { findProductTypeIdByHandle } from '~/lib/utils'; + +import { getCartReservations } from '~/lib/services/reservation.service'; import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; +import { EnrichedCartItem } from '../../_components/cart/types'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -37,10 +39,6 @@ async function CartPage() { productTypes, 'analysis-packages', ); - const ttoServiceTypeId = findProductTypeIdByHandle( - productTypes, - 'tto-service', - ); const synlabAnalyses = analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items @@ -54,14 +52,11 @@ async function CartPage() { ); }) : []; - const ttoServiceItems = - ttoServiceTypeId && cart?.items - ? cart?.items?.filter((item) => { - const productTypeId = item.product?.type_id; - return productTypeId && productTypeId === ttoServiceTypeId; - }) - : []; + let ttoServiceItems: EnrichedCartItem[] = []; + if (cart?.items?.length) { + ttoServiceItems = await getCartReservations(cart); + } const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1, ); diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index 77caa97..9d3b6e8 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,22 +1,46 @@ 'use client'; -import { ServiceCategory } from '../service-categories'; +import { StoreProduct } from '@medusajs/types'; + +import { Trans } from '@kit/ui/trans'; + +import { EnrichedCartItem } from '../cart/types'; import BookingCalendar from './booking-calendar'; import { BookingProvider } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; -const BookingContainer = ({ category }: { category: ServiceCategory }) => { +const BookingContainer = ({ + category, + cartItem, + onComplete, +}: { + category: { products: StoreProduct[]; countryCode: string }; + cartItem?: EnrichedCartItem; + onComplete?: () => void; +}) => { + const products = cartItem?.product ? [cartItem.product] : category.products; + + if (!cartItem || !products?.length) { +

+ +

; + } + return ( - +
- +
- +
); diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 3f3cb18..2c23a9f 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -4,7 +4,6 @@ import { StoreProduct } from '@medusajs/types'; import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service'; -import { ServiceCategory } from '../service-categories'; import { BookingContext, Location, TimeSlot } from './booking.context'; export function useBooking() { @@ -19,10 +18,11 @@ export function useBooking() { export const BookingProvider: React.FC<{ children: React.ReactElement; - category: ServiceCategory; -}> = ({ children, category }) => { + category: { products: StoreProduct[] }; + service?: StoreProduct; +}> = ({ children, category, service }) => { const [selectedService, setSelectedService] = useState( - category.products[0] || null, + (service ?? category?.products?.[0]) || null, ); const [selectedLocationId, setSelectedLocationId] = useState( null, @@ -32,6 +32,7 @@ export const BookingProvider: React.FC<{ const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); + useEffect(() => { let metadataServiceIds = []; try { diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 42540d1..8dd0907 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -18,7 +18,7 @@ import { useBooking } from './booking.provider'; const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { const { selectedService, setSelectedService } = useBooking(); const [collapsed, setCollapsed] = useState(false); - const [firstFourProducts] = useState(products.slice(0, 4)); + const [firstFourProducts] = useState(products?.slice(0, 4)); const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); @@ -38,7 +38,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { className="mb-2 flex flex-col" onValueChange={onServiceSelect} > - {firstFourProducts.map((product) => ( + {firstFourProducts?.map((product) => (
{
))} - -
setCollapsed((_) => !_)} - className="flex cursor-pointer items-center justify-between border-t py-1" - > - - - - -
-
+ {products.length > 4 && ( + +
setCollapsed((_) => !_)} + className="flex cursor-pointer items-center justify-between border-t py-1" + > + + + + +
+
+ )}
- {products.map((product) => ( + {products?.map((product) => (
{ +const TimeSlots = ({ + countryCode, + cartItem, + onComplete, +}: { + countryCode: string; + cartItem?: EnrichedCartItem; + onComplete?: () => void; +}) => { const [currentPage, setCurrentPage] = useState(1); const { @@ -133,6 +144,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { booking.selectedLocationId ? booking.selectedLocationId : null, comments, ).then(() => { + if (onComplete) { + onComplete(); + } router.push(pathsConfig.app.cart); }); @@ -143,6 +157,49 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { }); }; + const handleChangeTime = async ( + timeSlot: TimeSlot, + reservationId: number, + cartId: string, + ) => { + const syncedService = timeSlot.syncedService; + if (!syncedService) { + return toast.error(t('booking:serviceNotFound')); + } + + const bookTimePromise = updateReservationTime( + reservationId, + timeSlot.StartTime, + Number(syncedService.id), + timeSlot.UserID, + timeSlot.SyncUserID, + booking.selectedLocationId ? booking.selectedLocationId : null, + cartId, + ); + + toast.promise(() => bookTimePromise, { + success: , + error: , + loading: , + }); + + if (onComplete) { + onComplete(); + } + }; + + const handleTimeSelect = async (timeSlot: TimeSlot) => { + if (cartItem?.reservation.id) { + return handleChangeTime( + timeSlot, + cartItem.reservation.id, + cartItem.cart_id, + ); + } + + return handleBookTime(timeSlot); + }; + return (
@@ -154,9 +211,12 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { ); const price = booking.selectedService?.variants?.[0]?.calculated_price - ?.calculated_amount; + ?.calculated_amount ?? cartItem?.unit_price; return ( - +
{formatDateAndTime(timeSlot.StartTime.toString())}
@@ -177,11 +237,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { )} {isEHIF && {t('booking:ehifBooking')}}
-
- {timeSlot.location?.address} -
+
{timeSlot.location?.address}
-
+
{formatCurrency({ currencyCode: 'EUR', @@ -189,7 +247,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { value: price ?? '', })} -
diff --git a/app/home/(user)/_components/cart/cart-service-item.tsx b/app/home/(user)/_components/cart/cart-service-item.tsx new file mode 100644 index 0000000..a8d2bd7 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-service-item.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; + +import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@kit/ui/dialog'; +import { TableCell, TableRow } from '@kit/ui/table'; +import { Trans } from '@kit/ui/trans'; + +import BookingContainer from '../booking/booking-container'; +import CartItemDelete from './cart-item-delete'; +import { EnrichedCartItem } from './types'; + +const EditCartServiceItemModal = ({ + item, + onComplete, +}: { + item: EnrichedCartItem | null; + onComplete: () => void; +}) => { + if (!item) return null; + + return ( + + + + + + + + + + +
+ {item.product && item.reservation.countryCode ? ( + + ) : ( +

+ +

+ )} +
+
+
+ ); +}; + +export default function CartServiceItem({ + item, + currencyCode, + isUnavailable, +}: { + item: EnrichedCartItem; + currencyCode: string; + isUnavailable?: boolean; +}) { + const [editingItem, setEditingItem] = useState(null); + const { + i18n: { language }, + } = useTranslation(); + + return ( + <> + + +

+ {item.product_title} +

+
+ + + {formatDateAndTime(item.reservation.startTime.toString())} + + + + {item.reservation.location?.address ?? '-'} + + + {item.quantity} + + + {formatCurrency({ + value: item.unit_price, + currencyCode, + locale: language, + })} + + + + {formatCurrency({ + value: item.total, + currencyCode, + locale: language, + })} + + + + + + + + + + + + + +
+ {isUnavailable && + + + + } + setEditingItem(null)} + /> + + ); +} diff --git a/app/home/(user)/_components/cart/cart-service-items.tsx b/app/home/(user)/_components/cart/cart-service-items.tsx new file mode 100644 index 0000000..af39aa3 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-service-items.tsx @@ -0,0 +1,72 @@ +import { StoreCart } from '@medusajs/types'; + +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from '@kit/ui/table'; +import { Trans } from '@kit/ui/trans'; + +import CartServiceItem from './cart-service-item'; +import { EnrichedCartItem } from './types'; + +export default function CartServiceItems({ + cart, + items, + productColumnLabelKey, + unavailableLineItemIds +}: { + cart: StoreCart; + items: EnrichedCartItem[]; + productColumnLabelKey: string; + unavailableLineItemIds?: string[] +}) { + if (!items || items.length === 0) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {items + .sort((a, b) => + (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1, + ) + .map((item) => ( + + ))} + +
+ ); +} diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index 09064bd..5775906 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -1,22 +1,23 @@ -"use client"; +'use client'; + +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'; -import { useState } from "react"; -import { Loader2 } from "lucide-react"; -import { StoreCart, StoreCartLineItem } from "@medusajs/types" -import CartItems from "./cart-items" -import { Trans } from '@kit/ui/trans'; import { Button } from '@kit/ui/button'; -import { - Card, - CardContent, - CardHeader, -} from '@kit/ui/card'; -import DiscountCode from "./discount-code"; -import { initiatePaymentSession } from "@lib/data/cart"; -import { formatCurrency } from "@/packages/shared/src/utils"; -import { useTranslation } from "react-i18next"; -import { handleNavigateToPayment } from "@/lib/services/medusaCart.service"; -import AnalysisLocation from "./analysis-location"; +import { Card, CardContent, CardHeader } from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; + +import AnalysisLocation from './analysis-location'; +import CartItems from './cart-items'; +import CartServiceItems from './cart-service-items'; +import DiscountCode from './discount-code'; +import { EnrichedCartItem } from './types'; const IS_DISCOUNT_SHOWN = true as boolean; @@ -25,13 +26,16 @@ export default function Cart({ synlabAnalyses, ttoServiceItems, }: { - cart: StoreCart | null + cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; - ttoServiceItems: StoreCartLineItem[]; + ttoServiceItems: EnrichedCartItem[]; }) { - const { i18n: { language } } = useTranslation(); + const { + i18n: { language }, + } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); + const [unavailableLineItemIds, setUnavailableLineItemIds] = useState() const items = cart?.items ?? []; @@ -39,7 +43,10 @@ export default function Cart({ return (
-
+

@@ -60,8 +67,13 @@ export default function Cart({ 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; + const result = await handleNavigateToPayment({ language, paymentSessionId }); + if (result.url) { + window.location.href = result.url; + } + if (result.unavailableLineItemIds) { + setUnavailableLineItemIds(result.unavailableLineItemIds) + } } else { setIsInitiatingSession(false); } @@ -71,21 +83,30 @@ export default function Cart({ const isLocationsShown = synlabAnalyses.length > 0; return ( -
-
- - +
+
+ +
{hasCartItems && ( <> -
-
-

+

+
+

-

+

{formatCurrency({ value: cart.subtotal, currencyCode: cart.currency_code, @@ -94,14 +115,14 @@ export default function Cart({

-
-
-

+

+
+

-

+

{formatCurrency({ value: cart.discount_total, currencyCode: cart.currency_code, @@ -110,14 +131,14 @@ export default function Cart({

-
-
-

+

+
+

-

+

{formatCurrency({ value: cart.total, currencyCode: cart.currency_code, @@ -129,11 +150,9 @@ export default function Cart({ )} -

+
{IS_DISCOUNT_SHOWN && ( - +
@@ -146,24 +165,31 @@ export default function Cart({ )} {isLocationsShown && ( - +
- +
)}
-
diff --git a/app/home/(user)/_components/cart/types.ts b/app/home/(user)/_components/cart/types.ts index 22385ce..c0154c9 100644 --- a/app/home/(user)/_components/cart/types.ts +++ b/app/home/(user)/_components/cart/types.ts @@ -1,15 +1,18 @@ +import { StoreCartLineItem } from "@medusajs/types"; +import { Reservation } from "~/lib/types/reservation"; + export interface MontonioOrderToken { uuid: string; accessKey: string; merchantReference: string; merchantReferenceDisplay: string; paymentStatus: - | 'PAID' - | 'FAILED' - | 'CANCELLED' - | 'PENDING' - | 'EXPIRED' - | 'REFUNDED'; + | 'PAID' + | 'FAILED' + | 'CANCELLED' + | 'PENDING' + | 'EXPIRED' + | 'REFUNDED'; paymentMethod: string; grandTotal: number; currency: string; @@ -19,4 +22,11 @@ export interface MontonioOrderToken { paymentLinkUuid: string; iat: number; exp: number; -} \ No newline at end of file +} + +export enum CartItemType { + analysisOrders = 'analysisOrders', + ttoServices = 'ttoServices', +} + +export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation }; diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index dfb6f88..43d90b8 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -1,12 +1,10 @@ 'use server'; +import { updateLineItem } from '@lib/data/cart'; import { StoreProductVariant } from '@medusajs/types'; -import { updateLineItem } from "@lib/data/cart"; -import { - createInitialReservation -} from '~/lib/services/connected-online.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service'; +import { createInitialReservation } from '~/lib/services/reservation.service'; export async function createInitialReservationAction( selectedVariant: Pick, diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index 34abc24..32da35f 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -11,7 +11,6 @@ import { } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { Tables } from '@/packages/supabase/src/database.types'; -import { StoreOrder } from '@medusajs/types'; import axios from 'axios'; import { uniq, uniqBy } from 'lodash'; @@ -20,14 +19,15 @@ import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { TimeSlotResponse } from '~/home/(user)/_components/booking/booking.context'; + import { sendEmailFromTemplate } from './mailer.service'; -import { handleDeleteCartItem } from './medusaCart.service'; export async function getAvailableAppointmentsForService( serviceId: number, key: string, locationId: number | null, startTime?: Date, + maxDays?: number ) { try { const start = startTime ? { StartTime: startTime } : {}; @@ -41,7 +41,7 @@ export async function getAvailableAppointmentsForService( ServiceID: serviceId, Key: key, Lang: 'et', - MaxDays: 120, + MaxDays: maxDays ?? 120, LocationId: locationId ?? -1, ...start, }), @@ -202,8 +202,8 @@ export async function bookAppointment( }, param: JSON.stringify({ ClinicID: clinic.id, - ServiceID: service.id, - ClinicServiceID: service.sync_id, + ServiceID: service.sync_id, + ClinicServiceID: service.id, UserID: appointmentUserId, SyncUserID: syncUserID, StartTime: startTime, @@ -416,102 +416,3 @@ export async function getAvailableTimeSlotsForDisplay( ), }; } - -export async function createInitialReservation( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserID: number, - startTime: Date, - medusaLineItemId: string, - locationId?: number | null, - comments = '', -) { - const logger = await getLogger(); - const supabase = getSupabaseServerClient(); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - const userId = user?.id; - - if (!userId) { - throw new Error('User not authenticated'); - } - - logger.info( - 'Creating reservation' + - JSON.stringify({ serviceId, clinicId, startTime, userId }), - ); - - try { - const { data: createdReservation } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .insert({ - clinic_id: clinicId, - comments, - lang: 'et', - service_id: serviceId, - service_user_id: appointmentUserId, - start_time: startTime.toString(), - sync_user_id: syncUserID, - user_id: userId, - status: 'PENDING', - medusa_cart_line_item_id: medusaLineItemId, - location_sync_id: locationId, - }) - .select('id') - .single() - .throwOnError(); - - logger.info( - `Created reservation ${JSON.stringify({ createdReservation, userId })}`, - ); - - return createdReservation; - } catch (e) { - logger.error( - `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, - ); - await handleDeleteCartItem({ lineId: medusaLineItemId }); - throw e; - } -} - -export async function cancelReservation(medusaLineItemId: string) { - const supabase = getSupabaseServerClient(); - - return supabase - .schema('medreport') - .from('connected_online_reservation') - .update({ - status: 'CANCELLED', - }) - .eq('medusa_cart_line_item_id', medusaLineItemId) - .throwOnError(); -} - -export async function getOrderedTtoServices({ - medusaOrder, -}: { - medusaOrder: StoreOrder; -}) { - const supabase = getSupabaseServerClient(); - - const ttoReservationIds: number[] = - medusaOrder.items - ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) - .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? - []; - - const { data: orderedTtoServices } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .select('*') - .in('id', ttoReservationIds) - .throwOnError(); - - return orderedTtoServices; -} diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index cb63b2a..1da98b3 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -6,8 +6,23 @@ import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { z } from 'zod'; + + + import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { cancelReservation } from './connected-online.service'; + + + +import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; + + + +import { isSameMinute } from 'date-fns'; +import { getAvailableAppointmentsForService } from './connected-online.service'; + + + + const env = () => z @@ -102,6 +117,30 @@ export async function handleNavigateToPayment({ if (!cart) { throw new Error('No cart found'); } + const orderedTtoServices = await getOrderedTtoServices({ cart }); + + if (orderedTtoServices?.length) { + const unavailableLineItemIds: string[] = [] + for (const ttoService of orderedTtoServices) { + const availabilities = await getAvailableAppointmentsForService( + ttoService.service_id, + ttoService.provider.key, + ttoService.location_sync_id, + new Date(ttoService.start_time), + 1, + ); + const isAvailable = availabilities?.T_Booking?.length ? availabilities.T_Booking.find((timeSlot) => isSameMinute(ttoService.start_time, timeSlot.StartTime)) : false + + if (!isAvailable) { + unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!) + } + } + + if (unavailableLineItemIds.length) { + return { unavailableLineItemIds } + } + } + const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ @@ -120,11 +159,12 @@ export async function handleNavigateToPayment({ cart_id: cart.id, changed_by: user.id, }); + if (error) { throw new Error('Error logging cart entry: ' + error.message); } - return paymentLink; + return { url: paymentLink }; } export async function handleLineItemTimeout({ @@ -149,4 +189,4 @@ export async function handleLineItemTimeout({ if (error) { throw new Error('Error logging cart entry: ' + error.message); } -} +} \ No newline at end of file diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 8010e5e..422dcac 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -154,8 +154,10 @@ export async function getAnalysisOrdersAdmin({ export async function getTtoOrders({ orderStatus, + lineItemIds }: { orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status']; + lineItemIds?: string[] } = {}) { const client = getSupabaseServerClient(); @@ -171,9 +173,15 @@ export async function getTtoOrders({ .from('connected_online_reservation') .select('*') .eq("user_id", user.id) + if (orderStatus) { query.eq('status', orderStatus); } + + if (lineItemIds?.length) { + query.in('medusa_cart_line_item_id', lineItemIds) + } + const orders = await query.order('created_at', { ascending: false }).throwOnError(); return orders.data; } \ No newline at end of file diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts new file mode 100644 index 0000000..b3a95ac --- /dev/null +++ b/lib/services/reservation.service.ts @@ -0,0 +1,333 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { listRegions } from '@lib/data/regions'; +import { StoreCart, StoreOrder } from '@medusajs/types'; + +import { getLogger } from '@kit/shared/logger'; +import { Tables } from '@kit/supabase/database'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { EnrichedCartItem } from '../../app/home/(user)/_components/cart/types'; +import { loadCurrentUserAccount } from '../../app/home/(user)/_lib/server/load-user-account'; +import { handleDeleteCartItem } from './medusaCart.service'; + +type Locations = Tables<{ schema: 'medreport' }, 'connected_online_locations'>; +type Services = Tables<{ schema: 'medreport' }, 'connected_online_services'>; +type ServiceProviders = Tables< + { schema: 'medreport' }, + 'connected_online_service_providers' +>; + +export async function getCartReservations( + medusaCart: StoreCart, +): Promise { + const supabase = getSupabaseServerClient(); + + const cartLineItemIds = medusaCart.items?.map(({ id }) => id); + + if (!cartLineItemIds?.length) { + return []; + } + + const { data: reservations } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select( + 'id, startTime:start_time, service:service_id, location:location_sync_id, serviceProvider:service_user_id, medusaCartLineItemId:medusa_cart_line_item_id', + ) + .in('medusa_cart_line_item_id', cartLineItemIds) + .throwOnError(); + + const locationSyncIds: number[] = + reservations + ?.filter((reservation) => !!reservation.location) + .map((reservation) => reservation.location!) ?? []; + const serviceIds = + reservations?.map((reservation) => reservation.service) ?? []; + const serviceProviderIds = + reservations.map((reservation) => reservation.serviceProvider) ?? []; + + let locations: + | { + syncId: Locations['sync_id']; + name: Locations['name']; + address: Locations['address']; + }[] + | null = null; + if (locationSyncIds.length) { + ({ data: locations } = await supabase + .schema('medreport') + .from('connected_online_locations') + .select('syncId:sync_id, name, address') + .in('sync_id', locationSyncIds) + .throwOnError()); + } + + let services: + | { + id: Services['id']; + name: Services['name']; + }[] + | null = null; + if (serviceIds.length) { + ({ data: services } = await supabase + .schema('medreport') + .from('connected_online_services') + .select('name, id') + .in('id', serviceIds) + .throwOnError()); + } + + let serviceProviders: + | { + id: ServiceProviders['id']; + name: ServiceProviders['name']; + jobTitleEt: ServiceProviders['job_title_et']; + jobTitleEn: ServiceProviders['job_title_en']; + jobTitleRu: ServiceProviders['job_title_ru']; + spokenLanguages: ServiceProviders['spoken_languages']; + }[] + | null = null; + if (serviceProviderIds.length) { + ({ data: serviceProviders } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .select( + 'id, name, jobTitleEt:job_title_et, jobTitleEn:job_title_en, jobTitleRu:job_title_ru, spokenLanguages:spoken_languages', + ) + .in('id', serviceProviderIds) + .throwOnError()); + } + + const results = []; + for (const reservation of reservations) { + if (reservation.medusaCartLineItemId === null) { + continue; + } + + const cartLineItem = medusaCart.items?.find( + (item) => item.id === reservation.medusaCartLineItemId, + ); + + if (!cartLineItem) { + continue; + } + + const location = locations?.find( + (location) => location.syncId === reservation.location, + ); + const service = services?.find( + (service) => service.id === reservation.service, + ); + const serviceProvider = serviceProviders?.find( + (serviceProvider) => serviceProvider.id === reservation.serviceProvider, + ); + + const countryCodes = await listRegions().then((regions) => + regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(), + ); + + const enrichedReservation = { + ...reservation, + location, + service, + serviceProvider, + }; + + results.push({ + ...cartLineItem, + reservation: { + ...enrichedReservation, + medusaCartLineItemId: reservation.medusaCartLineItemId!, + countryCode: countryCodes[0], + }, + }); + } + + return results; +} + +export async function createInitialReservation( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserID: number, + startTime: Date, + medusaLineItemId: string, + locationId?: number | null, + comments = '', +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + logger.info( + 'Creating reservation' + + JSON.stringify({ serviceId, clinicId, startTime, userId }), + ); + + try { + const { data: createdReservation } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .insert({ + clinic_id: clinicId, + comments, + lang: 'et', + service_id: serviceId, + service_user_id: appointmentUserId, + start_time: startTime.toString(), + sync_user_id: syncUserID, + user_id: userId, + status: 'PENDING', + medusa_cart_line_item_id: medusaLineItemId, + location_sync_id: locationId, + }) + .select('id') + .single() + .throwOnError(); + + logger.info( + `Created reservation ${JSON.stringify({ createdReservation, userId })}`, + ); + + return createdReservation; + } catch (e) { + logger.error( + `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, + ); + await handleDeleteCartItem({ lineId: medusaLineItemId }); + throw e; + } +} + +export async function cancelReservation(medusaLineItemId: string) { + const supabase = getSupabaseServerClient(); + + return supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'CANCELLED', + }) + .eq('medusa_cart_line_item_id', medusaLineItemId) + .throwOnError(); +} + +export async function getOrderedTtoServices({ + cart, + medusaOrder, +}: { + cart?: StoreCart; + medusaOrder?: StoreOrder; +}) { + const supabase = getSupabaseServerClient(); + + if (!medusaOrder && !cart) { + throw new Error('No cart or medusa order provided'); + } + + const ttoReservationIds: number[] = + (medusaOrder?.items ?? cart?.items) + ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) + .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? + []; + + const { data: orderedTtoServices } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select('*, provider:connected_online_providers(key)') + .in('id', ttoReservationIds) + .throwOnError(); + + 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, +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + const { account } = await loadCurrentUserAccount(); + + if (!userId || !account) { + throw new Error('User not authenticated'); + } + + const reservationData = JSON.stringify({ + reservationId, + newStartTime, + newServiceId, + newAppointmentUserId, + newSyncUserId, + newLocationId, + userId, + cartId, + }); + + logger.info('Updating reservation' + reservationData); + try { + await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + service_id: newServiceId, + service_user_id: newAppointmentUserId, + sync_user_id: newSyncUserId, + start_time: newStartTime.toString(), + location_sync_id: newLocationId, + }) + .eq('id', reservationId) + .eq('user_id', user.id) + .throwOnError(); + + logger.info(`Successfully updated reservation ${reservationData}`); + await supabase + .schema('audit') + .from('cart_entries') + .insert({ + operation: 'CHANGE_RESERVATION', + account_id: account.id, + cart_id: cartId, + changed_by: user.id, + comment: `${reservationData}`, + }); + revalidatePath('/home/cart', 'layout'); + } catch (e) { + logger.error(`Failed to update reservation ${reservationData}`); + await supabase + .schema('audit') + .from('cart_entries') + .insert({ + operation: 'CHANGE_RESERVATION', + account_id: account.id, + cart_id: cartId, + changed_by: user.id, + comment: `${e}`, + }); + throw e; + } +} diff --git a/lib/types/reservation.ts b/lib/types/reservation.ts new file mode 100644 index 0000000..447e745 --- /dev/null +++ b/lib/types/reservation.ts @@ -0,0 +1,35 @@ +import z from 'zod'; + +export const LocationSchema = z.object({ + syncId: z.number(), + name: z.string(), + address: z.string().nullable(), +}); +export type Location = z.infer; + +export const ServiceSchema = z.object({ + name: z.string(), + id: z.number(), +}); +export type Service = z.infer; + +export const ServiceProviderSchema = z.object({ + id: z.number(), + name: z.string(), + jobTitleEt: z.string().nullable(), + jobTitleEn: z.string().nullable(), + jobTitleRu: z.string().nullable(), + spokenLanguages: z.array(z.string()).nullable(), +}); +export type ServiceProvider = z.infer; + +export const ReservationSchema = z.object({ + startTime: z.string(), + service: ServiceSchema.optional(), + location: LocationSchema.optional(), + serviceProvider: ServiceProviderSchema.optional(), + medusaCartLineItemId: z.string(), + id: z.number(), + countryCode: z.string().optional(), +}); +export type Reservation = z.infer; diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index e00a03d..4676e8d 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -1137,7 +1137,22 @@ export type Database = { updated_at?: string | null user_id?: string } - Relationships: [] + Relationships: [ + { + foreignKeyName: "fk_reservation_clinic" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_reservation_service" + columns: ["service_id"] + isOneToOne: false + referencedRelation: "connected_online_services" + referencedColumns: ["id"] + }, + ] } connected_online_service_providers: { Row: { diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index 8a38d9f..5f30e95 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -6,5 +6,9 @@ "description": "Get to know the personal analysis packages and order" }, "noCategories": "List of services not found, try again later", - "noResults": "No availabilities found for selected dates" + "noResults": "No availabilities found for selected dates", + "serviceNotFound": "Service not found", + "bookTimeLoading": "Selecting time...", + "noProducts": "No products found", + "timeSlotUnavailable": "Service availability has changed, please select a new time" } \ No newline at end of file diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 223d3f1..63dbf49 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -7,7 +7,9 @@ "item": "Item", "quantity": "Quantity", "price": "Price", - "total": "Total" + "total": "Total", + "time": "Time", + "location": "Location" }, "checkout": { "goToCheckout": "Go to checkout", @@ -82,5 +84,9 @@ "title": "Location for analysis", "description": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point.", "locationSelect": "Select location" + }, + "editServiceItem": { + "title": "Edit booking", + "description": "Edit booking details" } } \ No newline at end of file diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json index bc2d2cd..a60b8ec 100644 --- a/public/locales/et/booking.json +++ b/public/locales/et/booking.json @@ -13,5 +13,8 @@ "showAllLocations": "Näita kõiki asutusi", "bookTimeSuccess": "Aeg valitud", "bookTimeError": "Aega ei õnnestunud valida", - "bookTimeLoading": "Aega valitakse..." + "bookTimeLoading": "Aega valitakse...", + "serviceNotFound": "Teenust ei leitud", + "noProducts": "Tooteid ei leitud", + "timeSlotUnavailable": "Teenuse saadavus muutus, palun vali uus aeg" } \ No newline at end of file diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index 2b2bfa5..41a77ae 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -7,7 +7,9 @@ "item": "Toode", "quantity": "Kogus", "price": "Hind", - "total": "Summa" + "total": "Summa", + "time": "Aeg", + "location": "Asukoht" }, "checkout": { "goToCheckout": "Vormista ost", @@ -84,5 +86,9 @@ "title": "Asukoht analüüside andmiseks", "description": "Kui Teil ei ole võimalik valitud asukohta minna analüüse andma, siis võite minna Teile sobivasse verevõtupunkti.", "locationSelect": "Vali asukoht" + }, + "editServiceItem": { + "title": "Muuda broneeringut", + "description": "Muuda broneeringu andmeid" } } \ No newline at end of file diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 7611586..8405f64 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -148,5 +148,6 @@ "yes": "Jah", "no": "Ei", "preferNotToAnswer": "Eelistan mitte vastata", - "book": "Broneeri" + "book": "Broneeri", + "change": "Muuda" } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index df29269..4987043 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -6,5 +6,9 @@ "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, "noCategories": "Список услуг не найден, попробуйте позже", - "noResults": "Для выбранных дат доступных вариантов не найдено" + "noResults": "Для выбранных дат доступных вариантов не найдено", + "bookTimeLoading": "Выбор времени...", + "serviceNotFound": "Услуга не найдена", + "noProducts": "Товары не найдены", + "timeSlotUnavailable": "Доступность услуги изменилась, пожалуйста, выберите другое время" } \ No newline at end of file diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 289ff31..279f3d2 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -7,7 +7,9 @@ "item": "Товар", "quantity": "Количество", "price": "Цена", - "total": "Сумма" + "total": "Сумма", + "time": "Время", + "location": "Местоположение" }, "checkout": { "goToCheckout": "Оформить заказ", @@ -82,5 +84,9 @@ "title": "Местоположение для сдачи анализов", "description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.", "locationSelect": "Выберите местоположение" + }, + "editServiceItem": { + "title": "Изменить бронирование", + "description": "Изменить данные бронирования" } } \ No newline at end of file diff --git a/supabase/migrations/20250919121028_add_references_to_reservations.sql b/supabase/migrations/20250919121028_add_references_to_reservations.sql new file mode 100644 index 0000000..30d9384 --- /dev/null +++ b/supabase/migrations/20250919121028_add_references_to_reservations.sql @@ -0,0 +1,12 @@ +ALTER TABLE medreport.connected_online_reservation +ADD CONSTRAINT fk_reservation_clinic +FOREIGN KEY (clinic_id) +REFERENCES medreport.connected_online_providers(id); + +ALTER TABLE medreport.connected_online_services +ADD CONSTRAINT constraint_name UNIQUE (sync_id); + +ALTER TABLE medreport.connected_online_reservation +ADD CONSTRAINT fk_reservation_service +FOREIGN KEY (service_id) +REFERENCES medreport.connected_online_services(id); \ No newline at end of file From 961f72652018163fd8d4fb19ced6d6ee0573bbe1 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 17:28:45 +0300 Subject: [PATCH 12/47] translations, remove random empty lines, refactor --- lib/services/audit/cartEntries.ts | 40 ++++++ lib/services/medusaCart.service.ts | 124 +++++++----------- lib/services/reservation.service.ts | 33 ++--- public/locales/en/booking.json | 12 +- public/locales/en/cart.json | 4 +- public/locales/en/common.json | 4 +- public/locales/en/orders.json | 27 +++- public/locales/ru/booking.json | 8 +- public/locales/ru/cart.json | 4 +- public/locales/ru/orders.json | 23 +++- ...onnected_online_service_provider_table.sql | 2 +- 11 files changed, 166 insertions(+), 115 deletions(-) create mode 100644 lib/services/audit/cartEntries.ts diff --git a/lib/services/audit/cartEntries.ts b/lib/services/audit/cartEntries.ts new file mode 100644 index 0000000..4b45b26 --- /dev/null +++ b/lib/services/audit/cartEntries.ts @@ -0,0 +1,40 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export const createCartEntriesLog = async ({ + operation, + accountId, + cartId, + variantId, + comment, +}: { + operation: string; + accountId: string; + cartId: string; + variantId?: string; + comment?: string; +}) => { + try { + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + console.error('No authenticated user found; skipping audit insert'); + return; + } + + return supabase.schema('audit').from('cart_entries').insert({ + operation, + account_id: accountId, + cart_id: cartId, + changed_by: user.id, + variant_id: variantId, + comment, + }); + } catch (error) { + console.error('Failed to insert doctor page view log', error); + } +}; diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index 1da98b3..46f2f08 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -5,25 +5,13 @@ import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; -import { z } from 'zod'; - - - -import { getSupabaseServerClient } from '@kit/supabase/server-client'; - - - -import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; - - - import { isSameMinute } from 'date-fns'; +import { z } from 'zod'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { createCartEntriesLog } from './audit/cartEntries'; import { getAvailableAppointmentsForService } from './connected-online.service'; - - - - const env = () => z .object({ @@ -50,8 +38,7 @@ export async function handleAddToCart({ selectedVariant: Pick; countryCode: string; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -63,16 +50,12 @@ export async function handleAddToCart({ countryCode, }); - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - variant_id: selectedVariant.id, + await createCartEntriesLog({ + variantId: selectedVariant.id, operation: 'ADD_TO_CART', - account_id: account.id, - cart_id: newCart.id, - changed_by: user.id, + accountId: account.id, + cartId: newCart.id, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } return { cart: newCart, addedItem }; } @@ -81,23 +64,18 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) { await deleteLineItem(lineId); await cancelReservation(lineId); - const supabase = getSupabaseServerClient(); const cartId = await getCartId(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - variant_id: lineId, + await createCartEntriesLog({ + variantId: lineId, operation: 'REMOVE_FROM_CART', - account_id: account.id, - cart_id: cartId!, - changed_by: user.id, + accountId: account.id, + cartId: cartId!, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } } export async function handleNavigateToPayment({ @@ -107,8 +85,7 @@ export async function handleNavigateToPayment({ language: string; paymentSessionId: string; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -117,30 +94,33 @@ export async function handleNavigateToPayment({ if (!cart) { throw new Error('No cart found'); } - const orderedTtoServices = await getOrderedTtoServices({ cart }); + const orderedTtoServices = await getOrderedTtoServices({ cart }); - if (orderedTtoServices?.length) { - const unavailableLineItemIds: string[] = [] - for (const ttoService of orderedTtoServices) { - const availabilities = await getAvailableAppointmentsForService( - ttoService.service_id, - ttoService.provider.key, - ttoService.location_sync_id, - new Date(ttoService.start_time), - 1, - ); - const isAvailable = availabilities?.T_Booking?.length ? availabilities.T_Booking.find((timeSlot) => isSameMinute(ttoService.start_time, timeSlot.StartTime)) : false - - if (!isAvailable) { - unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!) - } - } + if (orderedTtoServices?.length) { + const unavailableLineItemIds: string[] = []; + for (const ttoService of orderedTtoServices) { + const availabilities = await getAvailableAppointmentsForService( + ttoService.service_id, + ttoService.provider.key, + ttoService.location_sync_id, + new Date(ttoService.start_time), + 1, + ); + const isAvailable = availabilities?.T_Booking?.length + ? availabilities.T_Booking.find((timeSlot) => + isSameMinute(ttoService.start_time, timeSlot.StartTime), + ) + : false; - if (unavailableLineItemIds.length) { - return { unavailableLineItemIds } + if (!isAvailable) { + unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!); } } - + + if (unavailableLineItemIds.length) { + return { unavailableLineItemIds }; + } + } const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ @@ -152,17 +132,12 @@ export async function handleNavigateToPayment({ locale: language, merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`, }); - - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - operation: 'NAVIGATE_TO_PAYMENT', - account_id: account.id, - cart_id: cart.id, - changed_by: user.id, + + await createCartEntriesLog({ + operation: 'NAVIGATE_TO_PAYMENT', + accountId: account.id, + cartId: cart.id, }); - - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } return { url: paymentLink }; } @@ -172,21 +147,16 @@ export async function handleLineItemTimeout({ }: { lineItem: StoreCartLineItem; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } await deleteLineItem(lineItem.id); - const { error } = await supabase.schema('audit').from('cart_entries').insert({ + await createCartEntriesLog({ operation: 'LINE_ITEM_TIMEOUT', - account_id: account.id, - cart_id: lineItem.cart_id, - changed_by: user.id, + accountId: account.id, + cartId: lineItem.cart_id, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } } \ No newline at end of file diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index b3a95ac..330a8b2 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -11,6 +11,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { EnrichedCartItem } from '../../app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '../../app/home/(user)/_lib/server/load-user-account'; +import { createCartEntriesLog } from './audit/cartEntries'; import { handleDeleteCartItem } from './medusaCart.service'; type Locations = Tables<{ schema: 'medreport' }, 'connected_online_locations'>; @@ -305,29 +306,21 @@ export async function updateReservationTime( .throwOnError(); logger.info(`Successfully updated reservation ${reservationData}`); - await supabase - .schema('audit') - .from('cart_entries') - .insert({ - operation: 'CHANGE_RESERVATION', - account_id: account.id, - cart_id: cartId, - changed_by: user.id, - comment: `${reservationData}`, - }); + await createCartEntriesLog({ + operation: 'CHANGE_RESERVATION', + accountId: account.id, + cartId: cartId, + comment: `${reservationData}`, + }); revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); - await supabase - .schema('audit') - .from('cart_entries') - .insert({ - operation: 'CHANGE_RESERVATION', - account_id: account.id, - cart_id: cartId, - changed_by: user.id, - comment: `${e}`, - }); + await createCartEntriesLog({ + operation: 'CHANGE_RESERVATION', + accountId: account.id, + cartId: cartId, + comment: `${e}`, + }); throw e; } } diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index 5f30e95..e5eee34 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -5,10 +5,16 @@ "title": "Analysis packages", "description": "Get to know the personal analysis packages and order" }, - "noCategories": "List of services not found, try again later", - "noResults": "No availabilities found for selected dates", - "serviceNotFound": "Service not found", + "noCategories": "Service list not found, please try again later", + "noResults": "No available times on the selected dates", + "services": "Services", + "locations": "Locations", + "showAll": "Show all", + "showAllLocations": "Show all locations", + "bookTimeSuccess": "Time selected", + "bookTimeError": "Failed to select time", "bookTimeLoading": "Selecting time...", + "serviceNotFound": "Service not found", "noProducts": "No products found", "timeSlotUnavailable": "Service availability has changed, please select a new time" } \ No newline at end of file diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 63dbf49..544719b 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -16,7 +16,9 @@ "goToDashboard": "Continue", "error": { "title": "Something went wrong", - "description": "Please try again later." + "description": "Please try again later.", + "BOOKING_FAILED": "Service error, please try again later.", + "TIME_SLOT_UNAVAILABLE": "The selected time is not available." }, "timeLeft": "Time left {{timeLeft}}", "timeoutTitle": "Reservation expired", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index cf41acd..7c1978a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -147,5 +147,7 @@ "language": "Language", "yes": "Yes", "no": "No", - "preferNotToAnswer": "Prefer not to answer" + "preferNotToAnswer": "Prefer not to answer", + "book": "Book", + "change": "Change" } \ No newline at end of file diff --git a/public/locales/en/orders.json b/public/locales/en/orders.json index 7aa958c..c3077f5 100644 --- a/public/locales/en/orders.json +++ b/public/locales/en/orders.json @@ -9,12 +9,27 @@ "status": "Status" }, "status": { - "QUEUED": "Waiting to send to lab", - "PROCESSING": "Waiting for results", - "PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response", - "FULL_ANALYSIS_RESPONSE": "All analysis responses received, waiting for doctor response", - "COMPLETED": "Completed", + "QUEUED": "Queued", + "PROCESSING": "Processing", + "PARTIAL_ANALYSIS_RESPONSE": "Partial results", + "FULL_ANALYSIS_RESPONSE": "All results received", + "COMPLETED": "Confirmed", "REJECTED": "Rejected", - "CANCELLED": "Cancelled" + "CANCELLED": "Cancelled", + "analysisOrder": { + "QUEUED": "Queued", + "PROCESSING": "Sent to Synlab", + "PARTIAL_ANALYSIS_RESPONSE": "Partial results", + "FULL_ANALYSIS_RESPONSE": "All results received, awaiting doctor's summary", + "COMPLETED": "Confirmed", + "REJECTED": "Rejected", + "CANCELLED": "Cancelled" + }, + "ttoService": { + "PENDING": "Started", + "CONFIRMED": "Confirmed", + "REJECTED": "Rejected", + "CANCELLED": "Cancelled" + } } } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index 4987043..95b163a 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -6,7 +6,13 @@ "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, "noCategories": "Список услуг не найден, попробуйте позже", - "noResults": "Для выбранных дат доступных вариантов не найдено", + "noResults": "На выбранные даты нет свободного времени", + "services": "Услуги", + "locations": "Учреждения", + "showAll": "Показать все", + "showAllLocations": "Показать все учреждения", + "bookTimeSuccess": "Время выбрано", + "bookTimeError": "Не удалось выбрать время", "bookTimeLoading": "Выбор времени...", "serviceNotFound": "Услуга не найдена", "noProducts": "Товары не найдены", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 279f3d2..1eae1a4 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -16,7 +16,9 @@ "goToDashboard": "Продолжить", "error": { "title": "Что-то пошло не так", - "description": "Пожалуйста, попробуйте позже." + "description": "Пожалуйста, попробуйте позже.", + "BOOKING_FAILED": "Ошибка сервиса, попробуйте позже.", + "TIME_SLOT_UNAVAILABLE": "Выбранное время недоступно." }, "timeLeft": "Осталось времени {{timeLeft}}", "timeoutTitle": "Бронирование истекло", diff --git a/public/locales/ru/orders.json b/public/locales/ru/orders.json index 6669aff..e013422 100644 --- a/public/locales/ru/orders.json +++ b/public/locales/ru/orders.json @@ -10,11 +10,26 @@ }, "status": { "QUEUED": "Отправлено", - "PROCESSING": "Передано в Synlab", + "PROCESSING": "В обработке", "PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты", - "FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача", + "FULL_ANALYSIS_RESPONSE": "Все результаты получены", "COMPLETED": "Подтверждено", - "REJECTED": "Возвращено", - "CANCELLED": "Отменено" + "REJECTED": "Отклонено", + "CANCELLED": "Отменено", + "analysisOrder": { + "QUEUED": "Отправлено", + "PROCESSING": "Отправлено в Synlab", + "PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты", + "FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача", + "COMPLETED": "Подтверждено", + "REJECTED": "Отклонено", + "CANCELLED": "Отменено" + }, + "ttoService": { + "PENDING": "Начато", + "CONFIRMED": "Подтверждено", + "REJECTED": "Отклонено", + "CANCELLED": "Отменено" + } } } \ No newline at end of file diff --git a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql index 85dd813..6c647de 100644 --- a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql +++ b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql @@ -16,7 +16,7 @@ create table "medreport"."connected_online_service_providers" ( ALTER TABLE audit.request_entries DROP COLUMN personal_code; -ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid() references auth.users(id); +ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid(); create policy "insert_own" on "audit"."request_entries" From 249611886b35d6e68df3240231a94b4c13323714 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 17:30:32 +0300 Subject: [PATCH 13/47] remove unused import --- lib/services/medusaCart.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index 46f2f08..f1e013b 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -7,7 +7,6 @@ import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { isSameMinute } from 'date-fns'; import { z } from 'zod'; -import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; import { createCartEntriesLog } from './audit/cartEntries'; import { getAvailableAppointmentsForService } from './connected-online.service'; From 07237dece6fae3f993552c50230ff882849157bc Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 24 Sep 2025 14:57:52 +0300 Subject: [PATCH 14/47] 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 41593be44a34fcf24ffe5361c504daf6138fd2d5 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 15:14:13 +0300 Subject: [PATCH 15/47] fix types --- packages/supabase/src/database.types.ts | 264 ++++++++++++++++++------ 1 file changed, 196 insertions(+), 68 deletions(-) diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index ac8dc32..d9a0c45 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -199,7 +199,6 @@ export type Database = { changed_by: string created_at: string extra_data: Json | null - extra_data: Json | null id: number } Insert: { @@ -208,7 +207,6 @@ export type Database = { changed_by: string created_at?: string extra_data?: Json | null - extra_data?: Json | null id?: number } Update: { @@ -217,7 +215,6 @@ export type Database = { changed_by?: string created_at?: string extra_data?: Json | null - extra_data?: Json | null id?: number } Relationships: [] @@ -340,6 +337,91 @@ export type Database = { } medreport: { Tables: { + account_balance_entries: { + Row: { + account_id: string + amount: number + created_at: string + created_by: string | null + description: string | null + entry_type: string + expires_at: string | null + id: string + is_active: boolean + reference_id: string | null + source_company_id: string | null + } + Insert: { + account_id: string + amount: number + created_at?: string + created_by?: string | null + description?: string | null + entry_type: string + expires_at?: string | null + id?: string + is_active?: boolean + reference_id?: string | null + source_company_id?: string | null + } + Update: { + account_id?: string + amount?: number + created_at?: string + created_by?: string | null + description?: string | null + entry_type?: string + expires_at?: string | null + id?: string + is_active?: boolean + reference_id?: string | null + source_company_id?: string | null + } + Relationships: [ + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } account_params: { Row: { account_id: string @@ -751,9 +833,6 @@ export type Database = { response_value_is_negative: boolean | null response_value_is_within_norm: boolean | null status: string | null - response_value_is_negative: boolean | null - response_value_is_within_norm: boolean | null - status: string | null unit: string | null updated_at: string | null } @@ -772,12 +851,9 @@ export type Database = { original_response_element: Json response_time?: string | null response_value?: number | null - response_time?: string | null - response_value?: number | null response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null status?: string | null - status?: string | null unit?: string | null updated_at?: string | null } @@ -799,7 +875,6 @@ export type Database = { response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null status?: string | null - status?: string | null unit?: string | null updated_at?: string | null } @@ -851,6 +926,64 @@ export type Database = { }, ] } + benefit_distribution_schedule: { + Row: { + benefit_amount: number + benefit_occurrence: string + company_id: string + created_at: string + id: string + is_active: boolean + last_distributed_at: string | null + next_distribution_at: string + updated_at: string + } + Insert: { + benefit_amount: number + benefit_occurrence: string + company_id: string + created_at?: string + id?: string + is_active?: boolean + last_distributed_at?: string | null + next_distribution_at: string + updated_at?: string + } + Update: { + benefit_amount?: number + benefit_occurrence?: string + company_id?: string + created_at?: string + id?: string + is_active?: boolean + last_distributed_at?: string | null + next_distribution_at?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } billing_customers: { Row: { account_id: string @@ -1349,7 +1482,6 @@ export type Database = { id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at: string | null - updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1362,7 +1494,6 @@ export type Database = { id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at?: string | null - updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1375,7 +1506,6 @@ export type Database = { id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at?: string | null - updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1462,7 +1592,6 @@ export type Database = { Row: { action: string created_at: string | null - created_at: string | null has_analysis_results: boolean has_error: boolean id: string @@ -1472,13 +1601,6 @@ export type Database = { response_xml: string | null updated_at: string | null xml: string | null - id: string - medipost_external_order_id: string | null - medipost_private_message_id: string | null - medusa_order_id: string | null - response_xml: string | null - updated_at: string | null - xml: string | null } Insert: { action: string @@ -1505,31 +1627,7 @@ export type Database = { response_xml?: string | null updated_at?: string | null xml?: string | null - created_at?: string | null - has_analysis_results?: boolean - has_error?: boolean - id?: string - medipost_external_order_id?: string | null - medipost_private_message_id?: string | null - medusa_order_id?: string | null - response_xml?: string | null - updated_at?: string | null - xml?: string | null } - Update: { - action?: string - created_at?: string | null - has_analysis_results?: boolean - has_error?: boolean - id?: string - medipost_external_order_id?: string | null - medipost_private_message_id?: string | null - medusa_order_id?: string | null - response_xml?: string | null - updated_at?: string | null - xml?: string | null - } - Relationships: [] Relationships: [] } medreport_product_groups: { @@ -2097,10 +2195,23 @@ export type Database = { } Returns: Database["medreport"]["Tables"]["invitations"]["Row"][] } + calculate_next_distribution_date: { + Args: { p_current_date?: string; p_occurrence: string } + Returns: string + } can_action_account_member: { Args: { target_team_account_id: string; target_user_id: string } Returns: boolean } + consume_account_balance: { + Args: { + p_account_id: string + p_amount: number + p_description: string + p_reference_id?: string + } + Returns: boolean + } create_invitation: { Args: { account_id: string; email: string; role: string } Returns: { @@ -2154,6 +2265,18 @@ export type Database = { updated_by: string | null } } + distribute_health_benefits: { + Args: { + p_benefit_amount: number + p_benefit_occurrence?: string + p_company_id: string + } + Returns: undefined + } + get_account_balance: { + Args: { p_account_id: string } + Returns: number + } get_account_invitations: { Args: { account_slug: string } Returns: { @@ -2199,6 +2322,18 @@ export type Database = { personal_code: string }[] } + get_latest_analysis_response_elements_for_current_user: { + Args: { p_user_id: string } + Returns: { + analysis_name: string + analysis_name_lab: string + norm_lower: number + norm_status: number + norm_upper: number + response_time: string + response_value: number + }[] + } get_latest_medipost_dispatch_state_for_order: { Args: { medusa_order_id: string } Returns: { @@ -2301,9 +2436,10 @@ export type Database = { order_has_medipost_dispatch_error: { Args: { medusa_order_id: string } Returns: boolean - order_has_medipost_dispatch_error: { - Args: { medusa_order_id: string } - Returns: boolean + } + process_periodic_benefit_distributions: { + Args: Record + Returns: undefined } revoke_nonce: { Args: { p_id: string; p_reason?: string } @@ -2329,27 +2465,11 @@ export type Database = { Args: { new_owner_id: string; target_account_id: string } Returns: undefined } + trigger_benefit_distribution: { + Args: { p_company_id: string } + Returns: undefined + } update_account: { - Args: - | { - p_city: string - p_email: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - } - | { - p_city: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - } Args: | { p_city: string @@ -2388,6 +2508,14 @@ export type Database = { user_id: string } } + upsert_benefit_distribution_schedule: { + Args: { + p_benefit_amount: number + p_benefit_occurrence: string + p_company_id: string + } + Returns: undefined + } upsert_order: { Args: { billing_provider: Database["medreport"]["Enums"]["billing_provider"] From c7298d2b7ee3ed20b0a3a80a7d672b067867b95b Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 16:54:36 +0300 Subject: [PATCH 16/47] refactor --- app/home/(user)/(dashboard)/page.tsx | 1 - .../_components/booking/booking.provider.tsx | 18 +++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 967dc2c..22a671c 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -17,7 +17,6 @@ import DashboardCards from '../_components/dashboard-cards'; import Recommendations from '../_components/recommendations'; import RecommendationsSkeleton from '../_components/recommendations-skeleton'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; -import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index ceb4ab8..3ae2f1c 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -33,18 +33,14 @@ export const BookingProvider: React.FC<{ const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); useEffect(() => { - let metadataServiceIds = []; - try { - metadataServiceIds = JSON.parse( - selectedService?.metadata?.serviceIds as string, - ); - } catch (e) { - console.error(e); + const metadataServiceIds = selectedService?.metadata?.serviceIds as string; + if (metadataServiceIds) { + const json = JSON.parse(metadataServiceIds); + if (Array.isArray(json)) { + updateTimeSlots(json); + } } - if (metadataServiceIds.length) { - updateTimeSlots(metadataServiceIds); - } - }, [selectedService?.metadata?.serviceIds, selectedLocationId]); + }, [selectedService, selectedLocationId]); const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); From 6c6e7a6847ed3c8a5b098a8cdb30699ca54fa61e Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 18:45:29 +0300 Subject: [PATCH 17/47] fix conflict duplicates --- .../cart/montonio-callback/actions.ts | 72 +++++-------------- .../cart/montonio-callback/error/page.tsx | 7 +- app/home/(user)/(dashboard)/cart/page.tsx | 6 -- app/home/(user)/(dashboard)/order/page.tsx | 6 -- .../_components/booking/booking.provider.tsx | 1 + app/home/(user)/_components/cart/index.tsx | 51 ++----------- lib/services/medusaCart.service.ts | 4 +- .../medusa-storefront/src/lib/data/regions.ts | 2 +- 8 files changed, 29 insertions(+), 120 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index f8cd535..b1ccb05 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -1,10 +1,5 @@ 'use server'; -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 { 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'; @@ -12,37 +7,26 @@ import { listProductTypes } from '@lib/data/products'; import type { StoreOrder } from '@medusajs/types'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; + +import { AccountWithParams } from '@kit/accounts/types/accounts'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { - bookAppointment, -} from '~/lib/services/connected-online.service'; -import { FailureReason } from '~/lib/types/connected-online'; -import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; +import { bookAppointment } from '~/lib/services/connected-online.service'; import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; -import { AccountWithParams } from '@kit/accounts/types/accounts'; +import { + createAnalysisOrder, + getAnalysisOrder, +} from '~/lib/services/order.service'; import { getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { FailureReason } from '~/lib/types/connected-online'; 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({ const env = () => z .object({ @@ -66,13 +50,6 @@ const env = () => isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', }); - }) - .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, @@ -86,17 +63,9 @@ const sendEmail = async ({ analysisPackageName: string; partnerLocationName: string; language: string; - account: Pick; - email: string; - analysisPackageName: string; - partnerLocationName: string; - language: string; }) => { const client = getSupabaseServerAdminClient(); try { - const { renderSynlabAnalysisPackageEmail } = await import( - '@kit/email-templates' - ); const { renderSynlabAnalysisPackageEmail } = await import( '@kit/email-templates' ); @@ -133,7 +102,6 @@ const sendEmail = async ({ throw new Error(`Failed to send email, message=${error}`); } }; -}; async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -144,7 +112,6 @@ async function decodeOrderToken(orderToken: string) { if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { throw new Error('Payment not successful'); - throw new Error('Payment not successful'); } return decoded; @@ -154,12 +121,10 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); if (!cartId) { throw new Error('Cart ID not found'); - throw new Error('Cart ID not found'); } const cart = await retrieveCart(cartId); if (!cart) { throw new Error('Cart not found'); - throw new Error('Cart not found'); } return cart; } @@ -206,17 +171,12 @@ async function sendAnalysisPackageOrderEmail({ email, analysisPackageOrder, }: { - account: AccountWithParams; - email: string; account: AccountWithParams; email: string; analysisPackageOrder: { partnerLocationName: string; analysisPackageName: string; }; - partnerLocationName: string; - analysisPackageName: string; - }; }) { const { language } = await createI18nServerInstance(); const { analysisPackageName, partnerLocationName } = analysisPackageOrder; @@ -238,15 +198,18 @@ export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found in context'); - throw new Error('Account not found in context'); } try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); const orderContainsSynlabItems = !!orderedAnalysisElements?.length; @@ -272,7 +235,8 @@ export async function processMontonioCallback(orderToken: string) { const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); let bookServiceResults: { @@ -314,7 +278,6 @@ export async function processMontonioCallback(orderToken: string) { } } else { console.error('Missing email to send order result email', orderResult); - console.error('Missing email to send order result email', orderResult); } if (env().isEnabledDispatchOnMontonioCallback && orderContainsSynlabItems) { @@ -334,7 +297,6 @@ export async function processMontonioCallback(orderToken: string) { return { success: true, orderId }; } catch (error) { - console.error('Failed to place order', error); console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx index 49dd4c6..c84908a 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -2,20 +2,15 @@ import { use } from 'react'; import Link from 'next/link'; -import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; -import { Button } from '@kit/ui/button'; - +import { toArray } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert'; import { Trans } from '@kit/ui/trans'; -import { FailureReason } from '~/lib/types/connected-online'; -import { toArray } from '~/lib/utils'; - export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 1623229..4a2af1f 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -25,7 +25,6 @@ export async function generateMetadata() { async function CartPage() { const cart = await retrieveCart().catch((error) => { - console.error('Failed to retrieve cart', error); console.error('Failed to retrieve cart', error); return notFound(); }); @@ -75,11 +74,6 @@ async function CartPage() { synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} /> - ); } diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index f452e4b..2e9fe68 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -1,16 +1,11 @@ import React from 'react'; -import React from 'react'; - import { redirect } from 'next/navigation'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { listProductTypes } from '@lib/data/products'; import { Divider } from '@medusajs/ui'; -import { pathsConfig } from '@kit/shared/config'; -import { Divider } from '@medusajs/ui'; - import { pathsConfig } from '@kit/shared/config'; import { PageBody } from '@kit/ui/makerkit/page'; import { Trans } from '@kit/ui/trans'; @@ -92,7 +87,6 @@ async function OrdersPage() { /> ); - ); })} {analysisOrders.length === 0 && (
diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 3ae2f1c..b0c21e1 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -45,6 +45,7 @@ export const BookingProvider: React.FC<{ const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); try { + console.log('serviceIds', serviceIds, selectedLocationId); const response = await getAvailableTimeSlotsForDisplay( serviceIds, selectedLocationId, diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index f13b280..81fbdd4 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -1,14 +1,4 @@ 'use client'; -'use client'; - -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'; import { useState } from 'react'; @@ -36,7 +26,6 @@ export default function Cart({ synlabAnalyses, ttoServiceItems, }: { - cart: StoreCart | null; cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; ttoServiceItems: EnrichedCartItem[]; @@ -44,12 +33,10 @@ export default function Cart({ const { i18n: { language }, } = useTranslation(); - const { - i18n: { language }, - } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); - const [unavailableLineItemIds, setUnavailableLineItemIds] = useState() + const [unavailableLineItemIds, setUnavailableLineItemIds] = + useState(); const items = cart?.items ?? []; @@ -57,10 +44,6 @@ export default function Cart({ return (
-
{hasCartItems && ( <> -
-
-

@@ -127,7 +110,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.subtotal, @@ -137,9 +119,6 @@ export default function Cart({

-
-
-

@@ -147,7 +126,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.discount_total, @@ -157,9 +135,6 @@ export default function Cart({

-
-
-

@@ -167,7 +142,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.total, @@ -180,10 +154,8 @@ export default function Cart({ )} -

{IS_DISCOUNT_SHOWN && ( -
@@ -197,7 +169,6 @@ export default function Cart({ )} {isLocationsShown && ( -
@@ -219,14 +190,6 @@ export default function Cart({
-
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 2e2498577f498a4c0a639cdb3b5be723a23e8958 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 15:40:37 +0300 Subject: [PATCH 19/47] MED-103: create job --- app/api/job/handler/sync-connected-online.ts | 4 +++- ...500925153100_sync_connected_online_cron_job.sql | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/202500925153100_sync_connected_online_cron_job.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 07b7435..41528ba 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -38,7 +38,9 @@ function getSpokenLanguages(spokenLanguages?: string) { } export default async function syncConnectedOnline() { - const isProd = process.env.NODE_ENV === 'production'; + const isProd = !['test', 'localhost'].some((pathString) => + process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString), + ); const baseUrl = process.env.CONNECTED_ONLINE_URL; diff --git a/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql b/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql new file mode 100644 index 0000000..f3fde10 --- /dev/null +++ b/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql @@ -0,0 +1,14 @@ +select + cron.schedule( + 'sync-connected-online-every-night', -- Unique job name + '0 1 * * *', -- Cron schedule: every night at 04:00 (GMT +3) + $$ + select + net.http_post( + url := 'https://test.medreport.ee/api/job/sync-connected-online', + headers := jsonb_build_object( + 'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84' + ) + ) as request_id; + $$ + ); From 5d88121e78106e5973e6eb7bf751b97f2c59b465 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 15:51:43 +0300 Subject: [PATCH 20/47] refactor demo clinic condition --- app/api/job/handler/sync-connected-online.ts | 40 +++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 41528ba..80e89c9 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -86,34 +86,20 @@ export default async function syncConnectedOnline() { let serviceProviders; let jobTitleTranslations; // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment - const isDemoClinic = (clinicId: number) => clinicId === 2; - if (isProd) { - clinics = responseData.Data.T_Lic.filter(({ ID }) => !isDemoClinic(ID)); - services = responseData.Data.T_Service.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ); - serviceProviders = responseData.Data.T_Doctor.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ); - jobTitleTranslations = createTranslationMap( - responseData.Data.P_JobTitleTranslations.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ), - ); - } else { - clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); - services = responseData.Data.T_Service.filter(({ ClinicID }) => + const isDemoClinic = (clinicId: number) => + isProd ? clinicId !== 2 : clinicId === 2; + clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); + services = responseData.Data.T_Service.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => isDemoClinic(ClinicID), - ); - serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => - isDemoClinic(ClinicID), - ); - jobTitleTranslations = createTranslationMap( - responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => - isDemoClinic(ClinicID), - ), - ); - } + ), + ); const mappedClinics = clinics.map((clinic) => { return { From c69a1af0940c0d3a4f5915e31b4fd1bd9e009bbb Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 16:06:34 +0300 Subject: [PATCH 21/47] added CONNECTED_ONLINE_URL env From 85c6621b7a38598c35e3942a2ec736d505f203d4 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 16:11:08 +0300 Subject: [PATCH 22/47] use env --- lib/services/medusaCart.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index e91ef65..87efe47 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -31,8 +31,8 @@ const env = () => .min(1), }) .parse({ - medusaBackendPublicUrl: 'http://webhook.site:3000', - siteUrl: 'http://webhook.site:3000', + medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, }); export async function handleAddToCart({ From 2188b73f5433880cb1cc18f2041e4ba5285d771d Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:11:00 +0300 Subject: [PATCH 23/47] recreated montonio env From 0f2bfb74b456f87e02a5fd4ce270f8df33695139 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:24:44 +0300 Subject: [PATCH 24/47] disable revalidatePath for home/cart --- lib/services/reservation.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 330a8b2..223cacf 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -312,7 +312,7 @@ export async function updateReservationTime( cartId: cartId, comment: `${reservationData}`, }); - revalidatePath('/home/cart', 'layout'); + // revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); await createCartEntriesLog({ From 1acde486f27e707d7b0cbd0623ac21924f2c28a1 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:42:53 +0300 Subject: [PATCH 25/47] add dynamic export --- app/home/(user)/(dashboard)/cart/page.tsx | 2 ++ lib/services/reservation.service.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 4a2af1f..cef7d8b 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -15,6 +15,8 @@ import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; import { EnrichedCartItem } from '../../_components/cart/types'; +export const dynamic = 'force-dynamic'; + export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 223cacf..330a8b2 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -312,7 +312,7 @@ export async function updateReservationTime( cartId: cartId, comment: `${reservationData}`, }); - // revalidatePath('/home/cart', 'layout'); + revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); await createCartEntriesLog({ From 0a6137127116cb555d5dbffeb2b0f20fdade451b Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 18:07:10 +0300 Subject: [PATCH 26/47] refactor env --- app/home/(user)/(dashboard)/cart/page.tsx | 2 -- .../src/services/montonio-webhook-handler.service.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index cef7d8b..4a2af1f 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -15,8 +15,6 @@ import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; import { EnrichedCartItem } from '../../_components/cart/types'; -export const dynamic = 'force-dynamic'; - export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts b/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts index 97a2fd0..2df2be2 100644 --- a/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts +++ b/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts @@ -37,10 +37,11 @@ interface MontonioOrderToken { exp: number; } -const { secretKey } = MontonioServerEnvSchema.parse({ - apiUrl: process.env.MONTONIO_API_URL, - secretKey: process.env.MONTONIO_SECRET_KEY, -}); +const env = () => + MontonioServerEnvSchema.parse({ + apiUrl: process.env.MONTONIO_API_URL, + secretKey: process.env.MONTONIO_SECRET_KEY, + }); export class MontonioWebhookHandlerService implements BillingWebhookHandlerService @@ -50,6 +51,7 @@ export class MontonioWebhookHandlerService async verifyWebhookSignature(request: Request) { const logger = await getLogger(); + const { secretKey } = env(); let token: string; try { From fc63b9e7b798085387c41cb3ea2dba83f7a88ace Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 09:43:49 +0300 Subject: [PATCH 27/47] 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 28/47] 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 29/47] 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 30/47] 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 31/47] 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 35/47] 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 36/47] 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 37/47] 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 38/47] 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 39/47] 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 40/47] 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 41/47] 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 42/47] 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 c99beea46a3499277cd0265da2143beb4a683239 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Fri, 26 Sep 2025 16:18:50 +0300 Subject: [PATCH 43/47] add openai validation --- app/home/(user)/(dashboard)/page.tsx | 22 +++++++++---------- .../_lib/server/is-valid-open-ai-env.ts | 13 +++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 app/home/(user)/_lib/server/is-valid-open-ai-env.ts diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 22a671c..cdb7bcf 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -16,6 +16,7 @@ import Dashboard from '../_components/dashboard'; import DashboardCards from '../_components/dashboard-cards'; import Recommendations from '../_components/recommendations'; import RecommendationsSkeleton from '../_components/recommendations-skeleton'; +import { isValidOpenAiEnv } from '../_lib/server/is-valid-open-ai-env'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; export const generateMetadata = async () => { @@ -52,17 +53,16 @@ async function UserHomePage() { /> - {process.env.OPENAI_API_KEY && - process.env.PROMPT_ID_ANALYSIS_RECOMMENDATIONS && ( - <> -

- -

- }> - - - - )} + {(await isValidOpenAiEnv()) && ( + <> +

+ +

+ }> + + + + )}
); 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 new file mode 100644 index 0000000..d6dbfae --- /dev/null +++ b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts @@ -0,0 +1,13 @@ +import OpenAI from 'openai'; + +export const isValidOpenAiEnv = async () => { + const client = new OpenAI(); + + try { + await client.models.list(); + return true; + } catch (e) { + console.log('No openAI env'); + return false; + } +}; From 2d9e6f8df3dcca866557b46e417b736de2f6de00 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 16:34:10 +0300 Subject: [PATCH 44/47] 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 45/47] 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 46/47] 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) { From e4fcafa57c6cd9421a0448f10f1b056ff7033a70 Mon Sep 17 00:00:00 2001 From: Karli Date: Fri, 26 Sep 2025 17:23:23 +0300 Subject: [PATCH 47/47] feat(MED-97): add dev key for medusa benefits payment --- .env.development | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.development b/.env.development index 2cc0b56..4fdb0cc 100644 --- a/.env.development +++ b/.env.development @@ -43,6 +43,7 @@ MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true #MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false # MEDUSA +COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg== MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000