From 22f7fa134b5aba980071031e708b59d536204803 Mon Sep 17 00:00:00 2001 From: Helena Date: Wed, 17 Sep 2025 18:11:13 +0300 Subject: [PATCH] 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);