Merge branch 'main' into MED-57

This commit is contained in:
Danel Kungla
2025-08-25 12:25:18 +03:00
156 changed files with 2823 additions and 363 deletions

View File

@@ -59,7 +59,7 @@ export class MontonioOrderHandlerService {
if (error instanceof AxiosError) {
console.error(error.response?.data);
}
console.error("Failed to create payment link, params=${JSON.stringify(params)}", error);
console.error(`Failed to create payment link, params=${JSON.stringify(params)}`, error);
throw new Error("Failed to create payment link");
}
}

View File

@@ -31,7 +31,10 @@
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
"./services/*": "./src/lib/server/services/*.ts",
"./actions/*": "./src/lib/server/actions/*.ts",
"./schema/*": "./src/lib/server/schema/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"typesVersions": {
"*": {
@@ -40,4 +43,4 @@
]
}
}
}
}

View File

@@ -1,28 +0,0 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isDoctor } from '../lib/server/utils/is-doctor';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
/**
* DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
* If the user is not a doctor, we redirect to a 404.
* @param Component - The Page or Layout component to wrap
*/
export function DoctorGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function DoctorGuardServerComponentWrapper(params: Params) {
const client = getSupabaseServerClient();
const isUserDoctor = await isDoctor(client);
// if the user is not a super-admin, we redirect to a 404
if (!isUserDoctor) {
notFound();
}
return <Component {...params} />;
};
}

View File

@@ -0,0 +1,21 @@
import { formatDate, getPersonParameters } from '@kit/shared/utils';
export function getResultSetName(
title: string,
isPackage: boolean,
nrOfElements: number,
) {
return !isPackage && nrOfElements > 1 ? 'doctor:analyses' : title;
}
export function getDOBWithAgeStringFromPersonalCode(
personalCode: string | null,
) {
if (!personalCode) return 'common:unknown';
const person = getPersonParameters(personalCode);
if (!person) return 'common:unknown';
return `${formatDate(person.dob.toString())} (${person.age})`;
}

View File

@@ -0,0 +1,110 @@
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import {
DoctorAnalysisFeedbackTable,
DoctorJobSelect,
DoctorJobUnselect,
doctorAnalysisFeedbackSchema,
doctorJobSelectSchema,
doctorJobUnselectSchema,
} from '../schema/doctor-analysis.schema';
import {
selectJob,
submitFeedback,
unselectJob,
} from '../services/doctor-analysis.service';
import { doctorAction } from '../utils/doctor-action';
/**
* @name selectJobAction
* @description Creates a feedback item that ties doctor to the job and enables giving feedback.
*/
export const selectJobAction = doctorAction(
enhanceAction(
async ({ analysisOrderId, userId }: DoctorJobSelect) => {
const logger = await getLogger();
logger.info({ analysisOrderId }, `Selecting new job`);
await selectJob(analysisOrderId, userId);
logger.info({ analysisOrderId }, `Successfully selected`);
return { success: true };
},
{
schema: doctorJobSelectSchema,
},
),
);
/**
* @name unselectJobAction
* @description Removes the current user from a feedback item.
*/
export const unselectJobAction = doctorAction(
enhanceAction(
async ({ analysisOrderId }: DoctorJobUnselect) => {
const logger = await getLogger();
logger.info({ analysisOrderId }, `Removing doctor from job`);
await unselectJob(analysisOrderId);
logger.info(
{ analysisOrderId },
`Successfully removed current doctor from job`,
);
return { success: true };
},
{
schema: doctorJobUnselectSchema,
},
),
);
/**
* @name giveFeedbackAction
* @description Creates or updates doctor analysis feedback.
*/
export const giveFeedbackAction = doctorAction(
enhanceAction(
async ({
feedbackValue,
userId,
analysisOrderId,
status,
}: {
feedbackValue: string;
userId: DoctorAnalysisFeedbackTable['user_id'];
analysisOrderId: DoctorAnalysisFeedbackTable['analysis_order_id'];
status: DoctorAnalysisFeedbackTable['status'];
}) => {
const logger = await getLogger();
logger.info(
{ analysisOrderId },
`Submitting feedback for analysis order...`,
);
const result = await submitFeedback(
analysisOrderId,
userId,
feedbackValue,
status,
);
logger.info({ analysisOrderId }, `Successfully submitted feedback`);
return result;
},
{
schema: doctorAnalysisFeedbackSchema,
},
),
);

View File

@@ -0,0 +1,69 @@
'use server';
import { getLogger } from '@kit/shared/logger';
import {
getOpenResponses,
getOtherResponses,
getUserDoneResponses,
getUserInProgressResponses,
} from '@kit/doctor/services/doctor-analysis.service';
import { doctorAction } from '../utils/doctor-action';
export const getUserDoneResponsesAction = doctorAction(
async ({ page, pageSize }: { page: number; pageSize: number }) => {
const logger = await getLogger();
try {
const data = await getUserDoneResponses({ page, pageSize });
return { success: true, data };
} catch (error) {
logger.error(`Error fetching data for user completed jobs`, error);
return { success: false, error: 'Failed to fetch data from the server.' };
}
},
);
export const getUserInProgressResponsesAction = doctorAction(
async ({ page, pageSize }: { page: number; pageSize: number }) => {
const logger = await getLogger();
try {
const data = await getUserInProgressResponses({ page, pageSize });
return { success: true, data };
} catch (error) {
logger.error(`Error fetching data for user's in progress jobs`, error);
return { success: false, error: 'Failed to fetch data from the server.' };
}
},
);
export const getOtherResponsesAction = doctorAction(
async ({ page, pageSize }: { page: number; pageSize: number }) => {
const logger = await getLogger();
try {
const data = await getOtherResponses({ page, pageSize });
return { success: true, data };
} catch (error) {
logger.error(
`Error fetching data for other analysis response jobs`,
error,
);
return {
success: false,
error: 'Failed to fetch data from the server.',
};
}
},
);
export const getOpenResponsesAction = doctorAction(
async ({ page, pageSize }: { page: number; pageSize: number }) => {
const logger = await getLogger();
try {
const data = await getOpenResponses({ page, pageSize });
return { success: true, data };
} catch (error) {
logger.error(`Error fetching open analysis response jobs`, error);
return { success: false, error: 'Failed to fetch data from the server.' };
}
},
);

View File

@@ -0,0 +1,78 @@
import * as z from 'zod';
export const AnalysisOrderIdSchema = z.object({
id: z.number(),
medusa_order_id: z.string(),
analysis_element_ids: z.array(z.number()).nullable(),
});
export type AnalysisOrderId = z.infer<typeof AnalysisOrderIdSchema>;
export const DoctorFeedbackSchema = z
.object({
id: z.number(),
analysis_order_id: z.number(),
doctor_user_id: z.string().nullable(),
user_id: z.string(),
value: z.string().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
created_by: z.string(),
updated_by: z.string().nullable(),
status: z.string(),
})
.optional()
.nullable();
export type DoctorFeedback = z.infer<typeof DoctorFeedbackSchema>;
export const OrderSchema = z.object({
title: z.string(),
isPackage: z.boolean(),
analysisOrderId: z.number(),
});
export type Order = z.infer<typeof OrderSchema>;
export const PatientSchema = z.object({
userId: z.string(),
accountId: z.string(),
firstName: z.string(),
lastName: z.string().nullable(),
personalCode: z.string().nullable(),
phone: z.string().nullable(),
email: z.string().nullable(),
height: z.number().optional().nullable(),
weight: z.number().optional().nullable(),
});
export type Patient = z.infer<typeof PatientSchema>;
export const AnalysisResponsesSchema = z.object({
user_id: z.string(),
analysis_order_id: AnalysisOrderIdSchema,
});
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>;
export const AnalysisResponseSchema = z.object({
id: z.number(),
analysis_response_id: z.number(),
analysis_element_original_id: z.string(),
unit: z.string().nullable(),
response_value: z.number(),
response_time: z.string(),
norm_upper: z.number().nullable(),
norm_upper_included: z.boolean().nullable(),
norm_lower: z.number().nullable(),
norm_lower_included: z.boolean().nullable(),
norm_status: z.number().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
analysis_name: z.string().nullable(),
analysis_responses: AnalysisResponsesSchema,
});
export type AnalysisResponse = z.infer<typeof AnalysisResponseSchema>;
export const AnalysisResultDetailsSchema = z.object({
analysisResponse: z.array(AnalysisResponseSchema),
order: OrderSchema,
doctorFeedback: DoctorFeedbackSchema,
patient: PatientSchema,
});
export type AnalysisResultDetails = z.infer<typeof AnalysisResultDetailsSchema>;

View File

@@ -0,0 +1,135 @@
import z from 'zod/v3';
import { Database } from '@kit/supabase/database';
export const doctorJobSelectSchema = z.object({
userId: z.string().uuid(),
analysisOrderId: z.number(),
});
export type DoctorJobSelect = z.infer<typeof doctorJobSelectSchema>;
export const doctorJobUnselectSchema = z.object({
analysisOrderId: z.number(),
});
export type DoctorJobUnselect = z.infer<typeof doctorJobUnselectSchema>;
export const FeedbackStatus = z.enum(['STARTED', 'DRAFT', 'COMPLETED']);
export const doctorAnalysisFeedbackFormSchema = z.object({
feedbackValue: z.string().min(15),
userId: z.string().uuid(),
});
export type DoctorAnalysisFeedbackForm = z.infer<
typeof doctorAnalysisFeedbackFormSchema
>;
export const doctorAnalysisFeedbackSchema = z.object({
feedbackValue: z.string().min(15),
userId: z.string().uuid(),
analysisOrderId: z.number(),
status: FeedbackStatus,
});
export type DoctorAnalysisFeedback = z.infer<
typeof doctorAnalysisFeedbackSchema
>;
export type DoctorAnalysisFeedbackTable =
Database['medreport']['Tables']['doctor_analysis_feedback']['Row'];
export const AnalysisOrderIdSchema = z.object({
id: z.number(),
medusa_order_id: z.string(),
analysis_element_ids: z.array(z.number()).nullable(),
});
export type AnalysisOrderId = z.infer<typeof AnalysisOrderIdSchema>;
export const ElementSchema = z.object({
id: z.number(),
analysis_response_id: z.number(),
analysis_element_original_id: z.string(),
unit: z.string().nullable(),
response_value: z.number(),
response_time: z.string(),
norm_upper: z.number().nullable(),
norm_upper_included: z.boolean().nullable(),
norm_lower: z.number().nullable(),
norm_lower_included: z.boolean().nullable(),
norm_status: z.number().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
analysis_name: z.string().nullable(),
});
export type Element = z.infer<typeof ElementSchema>;
export const FeedbackSchema = z.object({
analysis_order_id: z.number(),
user_id: z.string(),
doctor_user_id: z.string().nullable(),
status: z.string(),
});
export type Feedback = z.infer<typeof FeedbackSchema>;
export const OrderSchema = z.object({
title: z.string().optional().nullable(),
isPackage: z.boolean(),
analysisOrderId: z.number(),
});
export type Order = z.infer<typeof OrderSchema>;
export const AccountSchema = z.object({
name: z.string(),
last_name: z.string().nullable(),
id: z.string(),
primary_owner_user_id: z.string(),
});
export type Account = z.infer<typeof AccountSchema>;
export const ResponseTableSchema = z.object({
id: z.number(),
analysis_order_id: AnalysisOrderIdSchema,
order_number: z.string(),
order_status: z.string(),
user_id: z.string(),
created_at: z.string(),
updated_at: z.string().nullable(),
elements: z.array(ElementSchema),
firstSampleGivenAt: z.string().nullable(),
patient: AccountSchema,
order: OrderSchema,
doctor: AccountSchema.optional().nullable(),
feedback: FeedbackSchema.optional(),
});
export type ResponseTable = z.infer<typeof ResponseTableSchema>;
export type AnalysisResponseBase = {
analysis_order_id: number & {
id: number;
medusa_order_id: string;
analysis_element_ids: number[] | null;
};
created_at: string;
id: number;
order_number: string;
order_status: Database['medreport']['Tables']['analysis_orders']['Row']['status'];
updated_at: string | null;
user_id: string;
};
export interface PaginationParams {
page?: number;
pageSize?: number;
}
export interface PaginatedData<T> {
data: T[];
pagination: {
currentPage: number;
totalPages: number;
totalCount: number;
pageSize: number;
};
}
export interface ServerActionResponse<T> {
success: boolean;
data?: T;
error?: string;
}

View File

@@ -0,0 +1,583 @@
import 'server-only';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
import {
AnalysisResponseBase,
DoctorAnalysisFeedbackTable,
PaginatedData,
PaginationParams,
ResponseTable,
} from '../schema/doctor-analysis.schema';
async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
const supabase = getSupabaseServerClient();
if (!analysisResponses?.length) return [];
const analysisResponseIds = analysisResponses.map((r) => r.id);
const medusaOrderIds = analysisResponses.map(
(r) => r.analysis_order_id.medusa_order_id,
);
const userIds = analysisResponses.map((response) => response.user_id);
const [
{ data: doctorFeedbackItems },
{ data: medusaOrderItems },
{ data: analysisResponseElements },
{ data: accounts },
] = await Promise.all([
supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id, user_id, doctor_user_id, status')
.in(
'analysis_order_id',
analysisResponses.map((r) => r.analysis_order_id.id),
),
supabase
.schema('public')
.from('order_item')
.select('order_id, item_id(product_title, product_type)')
.in('order_id', medusaOrderIds),
supabase
.schema('medreport')
.from('analysis_response_elements')
.select('*')
.in('analysis_response_id', analysisResponseIds),
supabase
.schema('medreport')
.from('accounts')
.select('name, last_name, id, primary_owner_user_id')
.in('primary_owner_user_id', userIds),
]);
const doctorUserIds =
doctorFeedbackItems
?.map((item) => item.doctor_user_id)
.filter((value) => value !== null) || [];
const { data: doctorAccounts } = doctorUserIds.length
? await supabase
.schema('medreport')
.from('accounts')
.select('name, last_name, id, primary_owner_user_id')
.in('primary_owner_user_id', doctorUserIds)
: { data: [] };
const allAccounts = [...(accounts ?? []), ...(doctorAccounts ?? [])];
return analysisResponses.map((analysisResponse) => {
const responseElements =
analysisResponseElements?.filter(
(element) => element.analysis_response_id === analysisResponse.id,
) || [];
const firstSampleGivenAt = responseElements.length
? responseElements.reduce((earliest, current) =>
new Date(current.response_time) < new Date(earliest.response_time)
? current
: earliest,
)?.response_time
: null;
const medusaOrder = medusaOrderItems?.find(
({ order_id }) =>
order_id === analysisResponse.analysis_order_id.medusa_order_id,
);
const patientAccount = allAccounts?.find(
({ primary_owner_user_id }) =>
analysisResponse.user_id === primary_owner_user_id,
);
const feedback = doctorFeedbackItems?.find(
({ analysis_order_id }) =>
analysis_order_id === analysisResponse.analysis_order_id.id,
);
const doctorAccount = allAccounts?.find(
({ primary_owner_user_id }) =>
feedback?.doctor_user_id === primary_owner_user_id,
);
const order = {
title: medusaOrder?.item_id.product_title,
isPackage:
medusaOrder?.item_id.product_type?.toLowerCase() === 'analysis package',
analysisOrderId: analysisResponse.analysis_order_id.id,
status: analysisResponse.order_status,
};
if (!patientAccount || !analysisResponse) {
throw new Error('Invalid data');
}
return {
...analysisResponse,
elements: responseElements,
firstSampleGivenAt,
patient: patientAccount,
doctor: doctorAccount,
order,
feedback,
};
});
}
export async function getUserInProgressResponses({
page = 1,
pageSize = 10,
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error('No user logged in.');
}
const { data: inProgressFeedback } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.eq('doctor_user_id', user.id)
.neq('status', 'COMPLETED');
if (!inProgressFeedback?.length) {
return {
data: [],
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
};
}
const analysisOrderIds = inProgressFeedback.map((f) => f.analysis_order_id);
const offset = (page - 1) * pageSize;
const {
data: analysisResponses,
error,
count,
} = await supabase
.schema('medreport')
.from('analysis_responses')
.select(
`
*,
analysis_order_id(id, medusa_order_id, analysis_element_ids)
`,
{ count: 'exact' },
)
.in('analysis_order_id', analysisOrderIds)
.range(offset, offset + pageSize - 1)
.order('created_at', { ascending: false });
if (error) {
throw new Error('Something went wrong');
}
const enrichedData = await enrichAnalysisData(analysisResponses);
const totalCount = count || 0;
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: enrichedData,
pagination: { currentPage: page, totalPages, totalCount, pageSize },
};
}
export async function getUserDoneResponses({
page = 1,
pageSize = 10,
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error('No user logged in.');
}
const { data: completedFeedback } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.eq('doctor_user_id', user.id)
.eq('status', 'COMPLETED');
if (!completedFeedback?.length) {
return {
data: [],
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
};
}
const analysisOrderIds = completedFeedback.map((f) => f.analysis_order_id);
const offset = (page - 1) * pageSize;
const {
data: analysisResponses,
error,
count,
} = await supabase
.schema('medreport')
.from('analysis_responses')
.select(
`
*,
analysis_order_id(id, medusa_order_id, status, analysis_element_ids)
`,
{ count: 'exact' },
)
.in('analysis_order_id', analysisOrderIds)
.range(offset, offset + pageSize - 1)
.order('created_at', { ascending: false });
if (error) throw error;
const enrichedData = await enrichAnalysisData(analysisResponses);
const totalCount = count || 0;
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: enrichedData,
pagination: { currentPage: page, totalPages, totalCount, pageSize },
};
}
export async function getOpenResponses({
page = 1,
pageSize = 10,
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
const supabase = getSupabaseServerClient();
const { data: assignedOrderIds } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.not('doctor_user_id', 'is', null);
const assignedIds = assignedOrderIds?.map((f) => f.analysis_order_id) || [];
const offset = (page - 1) * pageSize;
let query = supabase
.schema('medreport')
.from('analysis_responses')
.select(
`
*,
analysis_order_id(id, medusa_order_id, analysis_element_ids)
`,
{ count: 'exact' },
)
.order('created_at', { ascending: false });
if (assignedIds.length > 0) {
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
}
const {
data: analysisResponses,
error,
count,
} = await query.range(offset, offset + pageSize - 1);
if (error) throw error;
const enrichedData = await enrichAnalysisData(analysisResponses);
const totalCount = count || 0;
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: enrichedData,
pagination: { currentPage: page, totalPages, totalCount, pageSize },
};
}
export async function getOtherResponses({
page = 1,
pageSize = 10,
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error('No user logged in.');
}
const { data: otherFeedback } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.neq('doctor_user_id', user.id);
if (!otherFeedback?.length) {
return {
data: [],
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
};
}
const analysisOrderIds = otherFeedback.map((f) => f.analysis_order_id);
const offset = (page - 1) * pageSize;
const {
data: analysisResponses,
error,
count,
} = await supabase
.schema('medreport')
.from('analysis_responses')
.select(
`
*,
analysis_order_id(id, medusa_order_id, analysis_element_ids)
`,
{ count: 'exact' },
)
.in('analysis_order_id', analysisOrderIds)
.range(offset, offset + pageSize - 1)
.order('created_at', { ascending: false });
if (error) throw error;
const enrichedData = await enrichAnalysisData(analysisResponses);
const totalCount = count || 0;
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: enrichedData,
pagination: { currentPage: page, totalPages, totalCount, pageSize },
};
}
export async function getAnalysisResultsForDoctor(
id: number,
): Promise<AnalysisResultDetails> {
const supabase = getSupabaseServerClient();
const { data: analysisResponse, error } = await supabase
.schema('medreport')
.from(`analysis_response_elements`)
.select(
`*,
analysis_responses(user_id, analysis_order_id(id,medusa_order_id, analysis_element_ids))`,
)
.eq('analysis_response_id', id);
if (error) {
throw new Error('Something went wrong.');
}
const firstAnalysisResponse = analysisResponse?.[0];
const userId = firstAnalysisResponse?.analysis_responses.user_id;
const medusaOrderId =
firstAnalysisResponse?.analysis_responses?.analysis_order_id
?.medusa_order_id;
if (!analysisResponse?.length || !userId || !medusaOrderId) {
throw new Error('Failed to retrieve full analysis data.');
}
const [
{ data: medusaOrderItems, error: medusaOrderError },
{ data: accountWithParams, error: accountError },
{ data: doctorFeedback, error: feedbackError },
] = await Promise.all([
supabase
.schema('public')
.from('order_item')
.select(`order_id, item_id(product_title, product_type)`)
.eq('order_id', medusaOrderId),
supabase
.schema('medreport')
.from('accounts')
.select(
`primary_owner_user_id, id, name, last_name, personal_code, phone, email,
account_params(height,weight)`,
)
.eq('is_personal_account', true)
.eq('primary_owner_user_id', userId)
.limit(1),
await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select(`*`)
.eq(
'analysis_order_id',
firstAnalysisResponse.analysis_responses.analysis_order_id.id,
)
.limit(1),
]);
if (medusaOrderError || accountError || feedbackError) {
throw new Error('Something went wrong.');
}
if (!accountWithParams?.[0]) {
throw new Error('Account not found.');
}
const {
primary_owner_user_id,
id: accountId,
name,
email,
last_name,
personal_code,
phone,
account_params,
} = accountWithParams[0];
return {
analysisResponse,
order: {
title: medusaOrderItems?.[0]?.item_id.product_title ?? '-',
isPackage:
medusaOrderItems?.[0]?.item_id.product_type?.toLowerCase() ===
'analysis package',
analysisOrderId:
firstAnalysisResponse.analysis_responses.analysis_order_id.id,
},
doctorFeedback: doctorFeedback?.[0],
patient: {
userId: primary_owner_user_id,
accountId,
firstName: name,
lastName: last_name,
personalCode: personal_code,
phone,
email,
height: account_params?.[0]?.height,
weight: account_params?.[0]?.weight,
},
};
}
export async function selectJob(analysisOrderId: number, userId: string) {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.id) {
throw new Error('No user logged in.');
}
const { data: existingFeedback, error: existingFeedbackError } =
await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('doctor_user_id')
.eq('analysis_order_id', analysisOrderId)
.limit(1);
const jobAssignedToUserId = existingFeedback?.[0]?.doctor_user_id;
if (!!jobAssignedToUserId && jobAssignedToUserId !== user.id) {
throw new Error('Job already assigned to another doctor.');
}
const { data, error } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.upsert(
{
doctor_user_id: user.id,
user_id: userId,
analysis_order_id: analysisOrderId,
},
{ onConflict: 'analysis_order_id' },
);
if (error || existingFeedbackError) {
throw new Error('Something went wrong');
}
return data;
}
export async function unselectJob(analysisOrderId: number) {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.id) {
throw new Error('No user logged in.');
}
const { data: currentDoctorFeedback, error: currentDoctorFeedbackError } =
await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('id,doctor_user_id')
.eq('analysis_order_id', analysisOrderId)
.eq('doctor_user_id', user.id)
.limit(1);
if (!currentDoctorFeedback) {
throw new Error(
'Current user not assigned to give feedback to this set of analyses.',
);
}
if (currentDoctorFeedbackError) {
throw new Error('Error retrieving job data');
}
const { data, error } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.update({
doctor_user_id: null,
})
.eq('analysis_order_id', analysisOrderId)
.eq('doctor_user_id', user.id);
if (error) {
throw new Error('Something went wrong');
}
return data;
}
export async function submitFeedback(
analysisOrderId: DoctorAnalysisFeedbackTable['analysis_order_id'],
userId: DoctorAnalysisFeedbackTable['user_id'],
value: DoctorAnalysisFeedbackTable['value'],
status: DoctorAnalysisFeedbackTable['status'],
) {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.id) {
throw new Error('No user logged in.');
}
const { data, error } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.upsert(
{
doctor_user_id: user?.id,
user_id: userId,
analysis_order_id: analysisOrderId,
value,
status,
},
{ onConflict: 'analysis_order_id' },
);
if (error) {
throw new Error('Something went wrong');
}
return data;
}

View File

@@ -0,0 +1,24 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isDoctor } from './is-doctor';
/**
* @name doctorAction
* @description Wrap a server action to ensure the user is a doctor.
* @param fn
*/
export function doctorAction<Args, Response>(
fn: (params: Args) => Promise<Response>,
) {
return async (params: Args): Promise<Response> => {
const isUserDoctor = await isDoctor(getSupabaseServerClient());
if (!isUserDoctor) {
notFound();
}
return await fn(params);
};
}

View File

@@ -3,8 +3,23 @@
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx",
"./services/*": "./src/lib/server/services/*.ts",
"./actions/*": "./src/lib/server/actions/*.ts"
},
"include": [
"*.ts",
"src",
"app"
],
"exclude": [
"node_modules"
]
}
],
"paths": {
"@components/*": [
"./src/lib/*"
],
}
}

View File

@@ -1,7 +1,7 @@
import Image from 'next/image';
import { redirect } from 'next/navigation';
import { MedReportLogo } from '@/components/med-report-logo';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';

View File

@@ -14,7 +14,9 @@
"./utils": "./src/utils.ts",
"./hooks": "./src/hooks/index.ts",
"./events": "./src/events/index.tsx",
"./registry": "./src/registry/index.ts"
"./components/*": "./src/components/*.tsx",
"./registry": "./src/registry/index.ts",
"./config": "./src/config/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
@@ -32,4 +34,4 @@
]
}
}
}
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useCallback, useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { analytics } from '@kit/analytics';
import {
AppEvent,
AppEventType,
ConsumerProvidedEventTypes,
useAppEvents,
} from '@kit/shared/events';
import { isBrowser } from '@kit/shared/utils';
type AnalyticsMapping<
T extends ConsumerProvidedEventTypes = NonNullable<unknown>,
> = {
[K in AppEventType<T>]?: (event: AppEvent<T, K>) => unknown;
};
/**
* Hook to subscribe to app events and map them to analytics actions
* @param mapping
*/
function useAnalyticsMapping<T extends ConsumerProvidedEventTypes>(
mapping: AnalyticsMapping<T>,
) {
const appEvents = useAppEvents<T>();
useEffect(() => {
const subscriptions = Object.entries(mapping).map(
([eventType, handler]) => {
appEvents.on(eventType as AppEventType<T>, handler);
return () => appEvents.off(eventType as AppEventType<T>, handler);
},
);
return () => {
subscriptions.forEach((unsubscribe) => unsubscribe());
};
}, [appEvents, mapping]);
}
/**
* Define a mapping of app events to analytics actions
* Add new mappings here to track new events in the analytics service from app events
*/
const analyticsMapping: AnalyticsMapping = {
'user.signedIn': (event) => {
const { userId, ...traits } = event.payload;
if (userId) {
return analytics.identify(userId, traits);
}
},
'user.signedUp': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
'checkout.started': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
'user.updated': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
};
function AnalyticsProviderBrowser(props: React.PropsWithChildren) {
// Subscribe to app events and map them to analytics actions
useAnalyticsMapping(analyticsMapping);
// Report page views to the analytics service
useReportPageView(useCallback((url) => analytics.trackPageView(url), []));
// Render children
return props.children;
}
/**
* Provider for the analytics service
*/
export function AnalyticsProvider(props: React.PropsWithChildren) {
if (!isBrowser()) {
return props.children;
}
return <AnalyticsProviderBrowser>{props.children}</AnalyticsProviderBrowser>;
}
/**
* Hook to report page views to the analytics service
* @param reportAnalyticsFn
*/
function useReportPageView(reportAnalyticsFn: (url: string) => unknown) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = [pathname, searchParams.toString()].filter(Boolean).join('?');
reportAnalyticsFn(url);
}, [pathname, reportAnalyticsFn, searchParams]);
}

View File

@@ -0,0 +1,36 @@
import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
function LogoImage({
className,
compact = false,
}: {
className?: string;
width?: number;
compact?: boolean;
}) {
return <MedReportLogo compact={compact} className={className} />;
}
export function AppLogo({
href,
label,
className,
compact = false,
}: {
href?: string | null;
className?: string;
label?: string;
compact?: boolean;
}) {
if (href === null) {
return <LogoImage className={className} compact={compact} />;
}
return (
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
<LogoImage className={className} compact={compact} />
</Link>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { useCallback } from 'react';
import type { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { useMonitoring } from '@kit/monitoring/hooks';
import { useAppEvents } from '@kit/shared/events';
import { useAuthChangeListener } from '@kit/supabase/hooks/use-auth-change-listener';
import { pathsConfig } from '@kit/shared/config';
export function AuthProvider(props: React.PropsWithChildren) {
const dispatchEvent = useDispatchAppEventFromAuthEvent();
const onEvent = useCallback(
(event: AuthChangeEvent, session: Session | null) => {
dispatchEvent(event, session?.user.id, {
email: session?.user.email ?? '',
});
},
[dispatchEvent],
);
useAuthChangeListener({
appHomePath: pathsConfig.app.home,
onEvent,
});
return props.children;
}
function useDispatchAppEventFromAuthEvent() {
const { emit } = useAppEvents();
const monitoring = useMonitoring();
return useCallback(
(
type: AuthChangeEvent,
userId: string | undefined,
traits: Record<string, string> = {},
) => {
switch (type) {
case 'INITIAL_SESSION':
if (userId) {
emit({
type: 'user.signedIn',
payload: { userId, ...traits },
});
monitoring.identifyUser({ id: userId, ...traits });
}
break;
case 'SIGNED_IN':
if (userId) {
emit({
type: 'user.signedIn',
payload: { userId, ...traits },
});
monitoring.identifyUser({ id: userId, ...traits });
}
break;
case 'USER_UPDATED':
emit({
type: 'user.updated',
payload: { userId: userId!, ...traits },
});
break;
}
},
[emit, monitoring],
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from '@/public/assets/arrow-left';
import { Trans } from '@kit/ui/trans';
export function BackButton({ onBack }: { onBack?: () => void }) {
const router = useRouter();
return (
<form
action={() => {
if (onBack) {
onBack();
} else {
router.back();
}
}}
>
<button className="absolute top-4 left-4 flex cursor-pointer flex-row items-center gap-3">
<div className="flex items-center justify-center rounded-sm border p-3">
<ArrowLeft />
</div>
<span className="text-sm">
<Trans i18nKey="common:goBack" />
</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,52 @@
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Trans } from '@kit/ui/trans';
export default function ConfirmationModal({
isOpen,
onClose,
onConfirm,
titleKey,
descriptionKey,
cancelKey = 'common:cancel',
confirmKey = 'common:confirm',
}: {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
titleKey: string;
descriptionKey: string;
cancelKey?: string;
confirmKey?: string;
}) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={titleKey} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={descriptionKey} />
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Trans i18nKey={cancelKey} />
</Button>
<Button onClick={onConfirm}>
<Trans i18nKey={confirmKey} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,69 @@
import Link from 'next/link';
import { signOutAction } from '@/lib/actions/sign-out';
import { hasEnvVars } from '@/utils/supabase/check-env-vars';
import { createClient } from '@/utils/supabase/server';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
export default async function AuthButton() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!hasEnvVars) {
return (
<>
<div className="flex items-center gap-4">
<div>
<Badge
variant={'default'}
className="pointer-events-none font-normal"
>
Please update .env.local file with anon key and url
</Badge>
</div>
<div className="flex gap-2">
<Button
asChild
size="sm"
variant={'outline'}
disabled
className="pointer-events-none cursor-none opacity-75"
>
<Link href="/sign-in">Sign in</Link>
</Button>
<Button
asChild
size="sm"
variant={'default'}
disabled
className="pointer-events-none cursor-none opacity-75"
>
<Link href="example/sign-up">Sign up</Link>
</Button>
</div>
</div>
</>
);
}
return user ? (
<div className="flex items-center gap-4">
Hey, {user.email}!
<form action={signOutAction}>
<Button type="submit" variant={'outline'}>
Sign out
</Button>
</form>
</div>
) : (
<div className="flex gap-2">
<Button asChild size="sm" variant={'outline'}>
<Link href="/sign-in">Sign in</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { cn } from '@kit/ui/utils';
import { MedReportSmallLogo } from '../../../../public/assets/med-report-small-logo';
export const MedReportLogo = ({
className,
compact = false,
}: {
className?: string;
compact?: boolean;
}) => (
<div className={cn('flex justify-center gap-2', className)}>
<MedReportSmallLogo />
{!compact && (
<span className="text-foreground text-lg font-semibold tracking-tighter">
MedReport
</span>
)}
</div>
);

View File

@@ -0,0 +1,31 @@
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { cn } from '@kit/ui/utils';
export const PackageHeader = ({
title,
tagColor,
analysesNr,
language,
price,
}: {
title: string;
tagColor: string;
analysesNr: string;
language: string;
price: string | number;
}) => {
return (
<div className="space-y-1 text-center">
<p className="text-sm sm:text-lg sm:font-medium">{title}</p>
<h2 className="text-xl sm:text-4xl">
{formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})}
</h2>
<Badge className={cn('text-xs', tagColor)}>{analysesNr}</Badge>
</div>
);
};

View File

@@ -0,0 +1,61 @@
'use client';
import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user';
import { pathsConfig, featureFlagsConfig } from '@kit/shared/config';
const paths = {
home: pathsConfig.app.home,
admin: pathsConfig.app.admin,
doctor: pathsConfig.app.doctor,
personalAccountSettings: pathsConfig.app.personalAccountSettings,
};
const features = {
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
};
export function ProfileAccountDropdownContainer(props: {
user?: User;
showProfileName?: boolean;
account?: {
id: string | null;
name: string | null;
picture_url: string | null;
application_role: ApplicationRole | null;
};
accounts: {
label: string | null;
value: string | null;
image?: string | null;
application_role: ApplicationRole | null;
}[];
}) {
const signOut = useSignOut();
const user = useUser(props.user);
const userData = user.data;
if (!userData) {
return null;
}
return (
<PersonalAccountDropdown
className={'w-full'}
paths={paths}
features={features}
user={userData}
account={props.account}
accounts={props.accounts}
signOutRequested={() => signOut.mutateAsync()}
showProfileName={props.showProfileName}
/>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function ReactQueryProvider(props: React.PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { ThemeProvider } from 'next-themes';
import { CaptchaProvider } from '@kit/auth/captcha/client';
import { I18nProvider } from '@kit/i18n/provider';
import { MonitoringProvider } from '@kit/monitoring/components';
import { AnalyticsProvider } from '@kit/shared/components/analytics-provider';
import { AuthProvider } from '@kit/shared/components/auth-provider';
import { appConfig, authConfig, featureFlagsConfig } from '@kit/shared/config';
import { AppEventsProvider } from '@kit/shared/events';
import { If } from '@kit/ui/if';
import { VersionUpdater } from '@kit/ui/version-updater';
import { i18nResolver } from '../../../../lib/i18n/i18n.resolver';
import { getI18nSettings } from '../../../../lib/i18n/i18n.settings';
import { ReactQueryProvider } from './react-query-provider';
const captchaSiteKey = authConfig.captchaTokenSiteKey;
const CaptchaTokenSetter = dynamic(async () => {
if (!captchaSiteKey) {
return Promise.resolve(() => null);
}
const { CaptchaTokenSetter } = await import('@kit/auth/captcha/client');
return {
default: CaptchaTokenSetter,
};
});
type RootProvidersProps = React.PropsWithChildren<{
// The language to use for the app (optional)
lang?: string;
// The theme (light or dark or system) (optional)
theme?: string;
// The CSP nonce to pass to scripts (optional)
nonce?: string;
}>;
export function RootProviders({
lang,
theme = appConfig.theme,
nonce,
children,
}: RootProvidersProps) {
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
return (
<MonitoringProvider>
<AppEventsProvider>
<AnalyticsProvider>
<ReactQueryProvider>
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
<CaptchaProvider>
<CaptchaTokenSetter siteKey={captchaSiteKey} />
<AuthProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
nonce={nonce}
>
{children}
</ThemeProvider>
</AuthProvider>
</CaptchaProvider>
<If condition={featureFlagsConfig.enableVersionUpdater}>
<VersionUpdater />
</If>
</I18nProvider>
</ReactQueryProvider>
</AnalyticsProvider>
</AppEventsProvider>
</MonitoringProvider>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
import { Button } from '@medusajs/ui';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { getAnalysisElementMedusaProductIds } from '../../../../utils/medusa-product';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip';
export interface IAnalysisPackage {
titleKey: string;
price: number;
tagColor: string;
descriptionKey: string;
}
export default function SelectAnalysisPackage({
analysisPackage,
countryCode,
}: {
analysisPackage: StoreProduct;
countryCode: string;
}) {
const router = useRouter();
const {
t,
i18n: { language },
} = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id) return null;
setIsAddingToCart(true);
await handleAddToCart({
selectedVariant,
countryCode,
});
setIsAddingToCart(false);
router.push('/home/cart');
};
const titleKey = analysisPackage.title;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([
analysisPackage,
]);
const nrOfAnalyses = analysisElementMedusaProductIds.length;
const description = analysisPackage.description ?? '';
const subtitle = analysisPackage.subtitle ?? '';
const variant = analysisPackage.variants?.[0];
if (!variant) {
return null;
}
const price = variant.calculated_price?.calculated_amount ?? 0;
return (
<Card key={titleKey}>
<CardHeader className="relative">
{description && (
<ButtonTooltip
content={description}
className="absolute top-5 right-5 z-10"
/>
)}
<Image
src="/assets/card-image.png"
alt="background"
width={326}
height={195}
className="max-h-48 w-full opacity-10"
/>
</CardHeader>
<CardContent className="space-y-1 text-center">
<PackageHeader
title={t(titleKey)}
tagColor="bg-cyan"
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
<CardDescription>{subtitle}</CardDescription>
</CardContent>
<CardFooter>
<Button
className="w-full text-[10px] sm:text-sm"
onClick={() => handleSelect(variant)}
isLoading={isAddingToCart}
>
{!isAddingToCart && (
<Trans i18nKey="order-analysis-package:selectThisPackage" />
)}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,19 @@
import { Trans } from '@kit/ui/trans';
import { StoreProduct } from '@medusajs/types';
import SelectAnalysisPackage from './select-analysis-package';
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) {
return (
<div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? analysisPackages.map(
(product) => (
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
)) : (
<h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
</h4>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
export default function TableSkeleton({
rows = 1,
cols = 7,
}: {
rows?: number;
cols?: number;
}) {
return (
<Table className="w-full border-separate animate-pulse rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
{Array.from({ length: cols }).map((_, i) => (
<TableHead key={i} className="p-2">
<div className="h-4 w-22 rounded bg-gray-200"></div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="border-t border-gray-200">
{Array.from({ length: cols }).map((_, c) => (
<TableCell key={c}>
<div className="h-4 w-22 rounded bg-gray-200"></div>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,31 @@
import { Info } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
export function ButtonTooltip({
content,
className,
}: {
content?: string;
className?: string;
}) {
if (!content) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className={className} variant="outline" size="icon">
<Info className="size-4 cursor-pointer" />
</Button>
</TooltipTrigger>
<TooltipContent className='sm:max-w-[30vw] sm:leading-4'>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,30 @@
import { JSX } from 'react';
import { Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
export function InfoTooltip({
content,
icon,
}: {
content?: string;
icon?: JSX.Element;
}) {
if (!content) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
{icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,33 @@
import React, { JSX, ReactNode } from 'react';
import { cn } from '@kit/ui/utils';
export type SearchProps = React.InputHTMLAttributes<HTMLInputElement> & {
startElement?: string | JSX.Element;
className?: string;
};
const Search = React.forwardRef<HTMLInputElement, SearchProps>(
({ className, startElement, ...props }, ref) => {
return (
<div
className={cn(
'border-input ring-offset-background focus-within:ring-ring flex h-10 items-center rounded-md border bg-white pl-3 text-sm focus-within:ring-1 focus-within:ring-offset-2',
className,
)}
>
{!!startElement && startElement}
<input
{...props}
type="search"
ref={ref}
className="placeholder:text-muted-foreground w-full p-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
);
},
);
Search.displayName = 'Search';
export { Search };

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@kit/ui/button";
import { type ComponentProps } from "react";
import { useFormStatus } from "react-dom";
type Props = ComponentProps<typeof Button> & {
pendingText?: string;
};
export function SubmitButton({
children,
pendingText = "Submitting...",
...props
}: Props) {
const { pending } = useFormStatus();
return (
<Button type="submit" aria-disabled={pending} {...props}>
{pending ? pendingText : children}
</Button>
);
}

View File

@@ -0,0 +1,78 @@
import { z } from 'zod';
const production = process.env.NODE_ENV === 'production';
const AppConfigSchema = z
.object({
name: z
.string({
description: `This is the name of your SaaS. Ex. "Makerkit"`,
required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
})
.min(1),
title: z
.string({
description: `This is the default title tag of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
})
.min(1),
description: z.string({
description: `This is the default description of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`,
}),
url: z
.string({
required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`,
})
.url({
message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
}),
locale: z
.string({
description: `This is the default locale of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
})
.default('en'),
theme: z.enum(['light', 'dark', 'system']),
production: z.boolean(),
themeColor: z.string(),
themeColorDark: z.string(),
})
.refine(
(schema) => {
const isCI = process.env.NEXT_PUBLIC_CI;
if (isCI ?? !schema.production) {
return true;
}
return !schema.url.startsWith('http:');
},
{
message: `Please provide a valid HTTPS URL. Set the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
path: ['url'],
},
)
.refine(
(schema) => {
return schema.themeColor !== schema.themeColorDark;
},
{
message: `Please provide different theme colors for light and dark themes.`,
path: ['themeColor'],
},
);
const appConfig = AppConfigSchema.parse({
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
title: process.env.NEXT_PUBLIC_SITE_TITLE,
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
url: process.env.NEXT_PUBLIC_SITE_URL,
locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR,
themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK,
production,
});
export default appConfig;

View File

@@ -0,0 +1,73 @@
import type { Provider } from '@supabase/supabase-js';
import { z } from 'zod';
const providers: z.ZodType<Provider> = getProviders();
const AuthConfigSchema = z.object({
captchaTokenSiteKey: z
.string({
description: 'The reCAPTCHA site key.',
})
.optional(),
displayTermsCheckbox: z
.boolean({
description: 'Whether to display the terms checkbox during sign-up.',
})
.optional(),
providers: z.object({
password: z.boolean({
description: 'Enable password authentication.',
}),
magicLink: z.boolean({
description: 'Enable magic link authentication.',
}),
oAuth: providers.array(),
}),
});
const authConfig = AuthConfigSchema.parse({
// NB: This is a public key, so it's safe to expose.
// Copy the value from the Supabase Dashboard.
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
// whether to display the terms checkbox during sign-up
displayTermsCheckbox:
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true',
// NB: Enable the providers below in the Supabase Console
// in your production project
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['google'],
},
} satisfies z.infer<typeof AuthConfigSchema>);
export default authConfig;
function getProviders() {
return z.enum([
'apple',
'azure',
'bitbucket',
'discord',
'facebook',
'figma',
'github',
'gitlab',
'google',
'kakao',
'keycloak',
'linkedin',
'linkedin_oidc',
'notion',
'slack',
'spotify',
'twitch',
'twitter',
'workos',
'zoom',
'fly',
]);
}

View File

@@ -0,0 +1,8 @@
/*
Replace this file with your own billing configuration file.
Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products.
This file will never be overwritten by git updates
*/
import sampleSchema from './billing.sample.config';
export default sampleSchema;

View File

@@ -0,0 +1,148 @@
/**
* This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace
* the configuration with your own billing provider and products.
*/
import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
// The billing provider to use. This should be set in the environment variables
// and should match the provider in the database. We also add it here so we can validate
// your configuration against the selected provider at build time.
const provider = BillingProviderSchema.parse(
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
);
export default createBillingSchema({
// also update config.billing_provider in the DB to match the selected
provider,
// products configuration
products: [
{
id: 'starter',
name: 'Starter',
description: 'The perfect plan to get started',
currency: 'USD',
badge: `Value`,
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Starter',
cost: 9.99,
type: 'flat' as const,
},
],
},
{
name: 'Starter Yearly',
id: 'starter-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'starter-yearly',
name: 'Base',
cost: 99.99,
type: 'flat' as const,
},
],
},
],
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
{
id: 'pro',
name: 'Pro',
badge: `Popular`,
highlighted: true,
description: 'The perfect plan for professionals',
currency: 'USD',
plans: [
{
name: 'Pro Monthly',
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1PGOAVI1i3VnbZTqc69xaypm',
name: 'Base',
cost: 19.99,
type: 'flat',
},
],
},
{
name: 'Pro Yearly',
id: 'pro-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_pro_yearly',
name: 'Base',
cost: 199.99,
type: 'flat',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
],
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'The perfect plan for enterprises',
currency: 'USD',
plans: [
{
name: 'Enterprise Monthly',
id: 'enterprise-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_enterprise-monthly',
name: 'Base',
cost: 29.99,
type: 'flat',
},
],
},
{
name: 'Enterprise Yearly',
id: 'enterprise-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_enterprise_yearly',
name: 'Base',
cost: 299.9,
type: 'flat',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
'Feature 6',
'Feature 7',
],
},
],
});

View File

@@ -0,0 +1,112 @@
import { z } from 'zod';
type LanguagePriority = 'user' | 'application';
const FeatureFlagsSchema = z.object({
enableThemeToggle: z.boolean({
description: 'Enable theme toggle in the user interface.',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
}),
enableAccountDeletion: z.boolean({
description: 'Enable personal account deletion.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
}),
enableTeamDeletion: z.boolean({
description: 'Enable team deletion.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
}),
enableTeamAccounts: z.boolean({
description: 'Enable team accounts.',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
}),
enableTeamCreation: z.boolean({
description: 'Enable team creation.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
}),
enablePersonalAccountBilling: z.boolean({
description: 'Enable personal account billing.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
}),
enableTeamAccountBilling: z.boolean({
description: 'Enable team account billing.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
}),
languagePriority: z
.enum(['user', 'application'], {
required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`,
})
.default('application'),
enableNotifications: z.boolean({
description: 'Enable notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
}),
realtimeNotifications: z.boolean({
description: 'Enable realtime for the notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
}),
enableVersionUpdater: z.boolean({
description: 'Enable version updater',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
}),
});
const featureFlagsConfig = FeatureFlagsSchema.parse({
enableThemeToggle: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_THEME_TOGGLE,
true,
),
enableAccountDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION,
false,
),
enableTeamDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION,
false,
),
enableTeamAccounts: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS,
true,
),
enableTeamCreation: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION,
true,
),
enablePersonalAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING,
false,
),
enableTeamAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING,
false,
),
languagePriority: process.env
.NEXT_PUBLIC_LANGUAGE_PRIORITY as LanguagePriority,
enableNotifications: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
true,
),
realtimeNotifications: getBoolean(
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
false,
),
enableVersionUpdater: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER,
false,
),
} satisfies z.infer<typeof FeatureFlagsSchema>);
export default featureFlagsConfig;
function getBoolean(value: unknown, defaultValue: boolean) {
if (typeof value === 'string') {
return value === 'true';
}
return defaultValue;
}

View File

@@ -0,0 +1,21 @@
import appConfig from './app.config';
import authConfig from './auth.config';
import billingConfig from './billing.config';
import featureFlagsConfig from './feature-flags.config';
import pathsConfig from './paths.config';
import { personalAccountNavigationConfig } from './personal-account-navigation.config';
import {
createPath,
getTeamAccountSidebarConfig,
} from './team-account-navigation.config';
export {
appConfig,
authConfig,
billingConfig,
createPath,
featureFlagsConfig,
getTeamAccountSidebarConfig,
pathsConfig,
personalAccountNavigationConfig,
};

View File

@@ -0,0 +1,81 @@
import { z } from 'zod';
const PathsSchema = z.object({
auth: z.object({
signIn: z.string().min(1),
signUp: z.string().min(1),
verifyMfa: z.string().min(1),
callback: z.string().min(1),
passwordReset: z.string().min(1),
passwordUpdate: z.string().min(1),
updateAccount: z.string().min(1),
updateAccountSuccess: z.string().min(1),
membershipConfirmation: z.string().min(1),
}),
app: z.object({
home: z.string().min(1),
selectPackage: z.string().min(1),
booking: z.string().min(1),
myOrders: z.string().min(1),
analysisResults: z.string().min(1),
orderAnalysisPackage: z.string().min(1),
orderAnalysis: z.string().min(1),
orderHealthAnalysis: z.string().min(1),
personalAccountSettings: z.string().min(1),
personalAccountBilling: z.string().min(1),
personalAccountBillingReturn: z.string().min(1),
accountHome: z.string().min(1),
accountSettings: z.string().min(1),
accountBilling: z.string().min(1),
accountMembers: z.string().min(1),
accountBillingReturn: z.string().min(1),
joinTeam: z.string().min(1),
admin: z.string().min(1),
doctor: z.string().min(1),
myJobs: z.string().min(1),
completedJobs: z.string().min(1),
openJobs: z.string().min(1),
analysisDetails: z.string().min(1),
}),
});
const pathsConfig = PathsSchema.parse({
auth: {
signIn: '/auth/sign-in',
signUp: '/auth/sign-up',
verifyMfa: '/auth/verify',
callback: '/auth/callback',
passwordReset: '/auth/password-reset',
passwordUpdate: '/update-password',
updateAccount: '/auth/update-account',
updateAccountSuccess: '/auth/update-account/success',
membershipConfirmation: '/auth/membership-confirmation',
},
app: {
home: '/home',
personalAccountSettings: '/home/settings',
personalAccountBilling: '/home/billing',
personalAccountBillingReturn: '/home/billing/return',
accountHome: '/home/[account]',
accountSettings: `/home/[account]/settings`,
accountBilling: `/home/[account]/billing`,
accountMembers: `/home/[account]/members`,
accountBillingReturn: `/home/[account]/billing/return`,
joinTeam: '/join',
selectPackage: '/select-package',
booking: '/home/booking',
orderAnalysisPackage: '/home/order-analysis-package',
myOrders: '/home/order',
analysisResults: '/home/analysis-results',
orderAnalysis: '/home/order-analysis',
orderHealthAnalysis: '/home/order-health-analysis',
doctor: '/doctor',
admin: '/admin',
myJobs: '/doctor/my-jobs',
completedJobs: '/doctor/completed-jobs',
openJobs: '/doctor/open-jobs',
analysisDetails: 'doctor/analysis',
},
} satisfies z.infer<typeof PathsSchema>);
export default pathsConfig;

View File

@@ -0,0 +1,71 @@
import {
FileLineChart,
HeartPulse,
LineChart,
MousePointerClick,
ShoppingCart,
Stethoscope,
TestTube2,
} from 'lucide-react';
import { z } from 'zod';
import { pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
const iconClasses = 'w-4 stroke-[1.5px]';
const routes = [
{
children: [
{
label: 'common:routes.overview',
path: pathsConfig.app.home,
Icon: <LineChart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.booking',
path: pathsConfig.app.booking,
Icon: <MousePointerClick className={iconClasses} />,
end: true,
},
{
label: 'common:routes.myOrders',
path: pathsConfig.app.myOrders,
Icon: <ShoppingCart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.analysisResults',
path: pathsConfig.app.analysisResults,
Icon: <TestTube2 className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderAnalysisPackage',
path: pathsConfig.app.orderAnalysisPackage,
Icon: <HeartPulse className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderAnalysis',
path: pathsConfig.app.orderAnalysis,
Icon: <FileLineChart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderHealthAnalysis',
path: pathsConfig.app.orderHealthAnalysis,
Icon: <Stethoscope className={iconClasses} />,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export const personalAccountNavigationConfig = NavigationConfigSchema.parse({
routes,
style: 'custom',
sidebarCollapsed: false,
sidebarCollapsedStyle: 'icon',
});

View File

@@ -0,0 +1,56 @@
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
const iconClasses = 'w-4';
const getRoutes = (account: string) => [
{
label: 'common:routes.application',
children: [
{
label: 'common:routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
},
],
},
{
label: 'common:routes.settings',
collapsible: false,
children: [
{
label: 'common:routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common:routes.members',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
];
export function getTeamAccountSidebarConfig(account: string) {
return NavigationConfigSchema.parse({
routes: getRoutes(account),
style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE,
sidebarCollapsed: process.env.NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED,
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSED_STYLE,
});
}
export function createPath(path: string, account: string) {
return path.replace('[account]', account);
}

View File

@@ -1,3 +1,6 @@
import { format } from 'date-fns';
import Isikukood, { Gender } from 'isikukood';
/**
* Check if the code is running in a browser environment.
*/
@@ -21,3 +24,36 @@ export function formatCurrency(params: {
currency: params.currencyCode,
}).format(Number(params.value));
}
export function formatDateAndTime(date?: string) {
if (!date) return '-';
return format(date, 'dd.MM.yyyy HH:mm');
}
export function formatDate(date?: string) {
if (!date) return '-';
return format(date, 'dd.MM.yyyy');
}
export function getFullName(
firstName?: string | null,
lastName?: string | null,
) {
return [firstName ?? '', lastName ?? ''].join(' ');
}
export const getPersonParameters = (personalCode: string) => {
try {
const person = new Isikukood(personalCode);
return {
gender: person.getGender(),
dob: person.getBirthday(),
age: person.getAge(),
};
} catch (error) {
console.error(error);
return null;
}
};

View File

@@ -1012,6 +1012,53 @@ export type Database = {
},
]
}
doctor_analysis_feedback: {
Row: {
analysis_order_id: number
created_at: string
created_by: string
doctor_user_id: string | null
id: number
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at: string
updated_by: string | null
user_id: string
value: string | null
}
Insert: {
analysis_order_id: number
created_at?: string
created_by?: string
doctor_user_id?: string | null
id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string
updated_by?: string | null
user_id: string
value?: string | null
}
Update: {
analysis_order_id?: number
created_at?: string
created_by?: string
doctor_user_id?: string | null
id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string
updated_by?: string | null
user_id?: string
value?: string | null
}
Relationships: [
{
foreignKeyName: "doctor_analysis_feedback_analysis_order_id_fkey"
columns: ["analysis_order_id"]
isOneToOne: false
referencedRelation: "analysis_orders"
referencedColumns: ["id"]
},
]
}
invitations: {
Row: {
account_id: string
@@ -1864,22 +1911,6 @@ export type Database = {
}
Returns: undefined
}
update_analysis_order_status: {
Args: {
order_id: number
medusa_order_id_param: string
status_param: Database["medreport"]["Enums"]["analysis_order_status"]
}
Returns: {
analysis_element_ids: number[] | null
analysis_ids: number[] | null
created_at: string
id: number
medusa_order_id: string
status: Database["medreport"]["Enums"]["analysis_order_status"]
user_id: string
}
}
upsert_order: {
Args: {
target_account_id: string
@@ -1950,6 +1981,7 @@ export type Database = {
}
}
Enums: {
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
analysis_order_status:
| "QUEUED"
| "ON_HOLD"
@@ -7843,6 +7875,7 @@ export const Constants = {
},
medreport: {
Enums: {
analysis_feedback_status: ["STARTED", "DRAFT", "COMPLETED"],
analysis_order_status: [
"QUEUED",
"ON_HOLD",