Merge pull request #136 from MR-medreport/develop

main <- develop
This commit is contained in:
danelkungla
2025-10-09 19:01:09 +03:00
committed by GitHub
14 changed files with 233 additions and 150 deletions

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Table, TableBody } from '@kit/ui/shadcn/table'; import { Table, TableBody } from '@kit/ui/shadcn/table';
import MobileCartRow from './mobile-cart-row'; import MobileTableRow from './mobile-table-row';
const MobileCartItems = ({ const MobileCartItems = ({
item, item,
@@ -24,12 +24,12 @@ const MobileCartItems = ({
return ( return (
<Table className="border-separate rounded-lg border p-2"> <Table className="border-separate rounded-lg border p-2">
<TableBody> <TableBody>
<MobileCartRow <MobileTableRow
titleKey={productColumnLabelKey} titleKey={productColumnLabelKey}
value={item.product_title} value={item.product_title}
/> />
<MobileCartRow titleKey="cart:table.time" value={item.quantity} /> <MobileTableRow titleKey="cart:table.time" value={item.quantity} />
<MobileCartRow <MobileTableRow
titleKey="cart:table.price" titleKey="cart:table.price"
value={formatCurrency({ value={formatCurrency({
value: item.unit_price, value: item.unit_price,
@@ -37,7 +37,7 @@ const MobileCartItems = ({
locale: language, locale: language,
})} })}
/> />
<MobileCartRow <MobileTableRow
titleKey="cart:table.total" titleKey="cart:table.total"
value={ value={
item.total && item.total &&

View File

@@ -8,7 +8,7 @@ import { Button } from '@kit/ui/shadcn/button';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table'; import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import CartItemDelete from './cart-item-delete'; import CartItemDelete from './cart-item-delete';
import MobileCartRow from './mobile-cart-row'; import MobileTableRow from './mobile-table-row';
import { EnrichedCartItem } from './types'; import { EnrichedCartItem } from './types';
const MobileCartServiceItems = ({ const MobileCartServiceItems = ({
@@ -31,20 +31,20 @@ const MobileCartServiceItems = ({
return ( return (
<Table className="border-separate rounded-lg border p-2"> <Table className="border-separate rounded-lg border p-2">
<TableBody> <TableBody>
<MobileCartRow <MobileTableRow
titleKey={productColumnLabelKey} titleKey={productColumnLabelKey}
value={item.product_title} value={item.product_title}
/> />
<MobileCartRow <MobileTableRow
titleKey="cart:table.time" titleKey="cart:table.time"
value={formatDateAndTime(item.reservation.startTime.toString())} value={formatDateAndTime(item.reservation.startTime.toString())}
/> />
<MobileCartRow <MobileTableRow
titleKey="cart:table.location" titleKey="cart:table.location"
value={item.reservation.location?.address ?? '-'} value={item.reservation.location?.address ?? '-'}
/> />
<MobileCartRow titleKey="cart:table.quantity" value={item.quantity} /> <MobileTableRow titleKey="cart:table.quantity" value={item.quantity} />
<MobileCartRow <MobileTableRow
titleKey="cart:table.price" titleKey="cart:table.price"
value={formatCurrency({ value={formatCurrency({
value: item.unit_price, value: item.unit_price,
@@ -52,7 +52,7 @@ const MobileCartServiceItems = ({
locale: language, locale: language,
})} })}
/> />
<MobileCartRow <MobileTableRow
titleKey="cart:table.total" titleKey="cart:table.total"
value={ value={
item.total && item.total &&

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { Trans } from '@kit/ui/makerkit/trans'; import { Trans } from '@kit/ui/makerkit/trans';
import { TableCell, TableHead, TableRow } from '@kit/ui/shadcn/table'; import { TableCell, TableHead, TableRow } from '@kit/ui/shadcn/table';
const MobileCartRow = ({ const MobleTableRow = ({
titleKey, titleKey,
value, value,
}: { }: {
@@ -16,14 +16,9 @@ const MobileCartRow = ({
</TableHead> </TableHead>
<TableCell className="p-0 text-right"> <TableCell className="p-0 text-right">
<p <p className="txt-medium-plus text-ui-fg-base">{value}</p>
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{value}
</p>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
export default MobileCartRow; export default MobleTableRow;

View File

@@ -51,7 +51,7 @@ export default function OrderBlock({
</Link> </Link>
</div> </div>
)} )}
<div className="flex flex-col gap-4"> <div className="flex flex-col sm:gap-4">
{analysisOrder && ( {analysisOrder && (
<OrderItemsTable <OrderItemsTable
items={itemsAnalysisPackage} items={itemsAnalysisPackage}

View File

@@ -23,6 +23,7 @@ import { Trans } from '@kit/ui/trans';
import type { Order } from '~/lib/types/order'; import type { Order } from '~/lib/types/order';
import { cancelTtoBooking } from '../../_lib/server/actions'; import { cancelTtoBooking } from '../../_lib/server/actions';
import MobileTableRow from '../cart/mobile-table-row';
import { logAnalysisResultsNavigateAction } from './actions'; import { logAnalysisResultsNavigateAction } from './actions';
export type OrderItemType = 'analysisOrder' | 'ttoService'; export type OrderItemType = 'analysisOrder' | 'ttoService';
@@ -60,76 +61,130 @@ export default function OrderItemsTable({
}; };
return ( return (
<Table className="border-separate rounded-lg border"> <>
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <Table className="border-separate rounded-lg border p-2 sm:hidden">
<TableRow> <TableBody>
<TableHead className="px-6"> {items
<Trans i18nKey={title} /> .sort((a, b) =>
</TableHead> (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
<TableHead className="px-6"> )
<Trans i18nKey="orders:table.createdAt" /> .map((orderItem) => (
</TableHead> <div key={`${orderItem.id}-mobile`}>
{order.location && ( <MobileTableRow
titleKey={title}
value={orderItem.product_title || ''}
/>
<MobileTableRow
titleKey="orders:table.createdAt"
value={formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
/>
{order.location && (
<MobileTableRow
titleKey="orders:table.location"
value={order.location}
/>
)}
<MobileTableRow
titleKey="orders:table.status"
value={
isPackage
? `orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`
: `orders:status.${type}.${order?.status ?? 'CONFIRMED'}`
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end p-0 pt-2">
<Button size="sm" onClick={openDetailedView}>
<Trans i18nKey="analysis-results:view" />
</Button>
{isTtoservice && order.bookingCode && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell>
</TableRow>
</div>
))}
</TableBody>
</Table>
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.location" /> <Trans i18nKey={title} />
</TableHead> </TableHead>
)} <TableHead className="px-6">
<TableHead className="px-6"> <Trans i18nKey="orders:table.createdAt" />
<Trans i18nKey="orders:table.status" /> </TableHead>
</TableHead> {order.location && (
{isAnalysisOrder && <TableHead className="px-6"></TableHead>} <TableHead className="px-6">
</TableRow> <Trans i18nKey="orders:table.location" />
</TableHeader> </TableHead>
<TableBody> )}
{items <TableHead className="px-6">
.sort((a, b) => <Trans i18nKey="orders:table.status" />
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1, </TableHead>
) {isAnalysisOrder && <TableHead className="px-6"></TableHead>}
.map((orderItem) => ( </TableRow>
<TableRow className="w-full" key={orderItem.id}> </TableHeader>
<TableCell className="w-[100%] px-6 text-left"> <TableBody>
<p className="txt-medium-plus text-ui-fg-base"> {items
{orderItem.product_title} .sort((a, b) =>
</p> (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
</TableCell> )
.map((orderItem) => (
<TableCell className="px-6 whitespace-nowrap"> <TableRow className="w-full" key={orderItem.id}>
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} <TableCell className="w-[100%] px-6 text-left">
</TableCell> <p className="txt-medium-plus text-ui-fg-base">
{order.location && ( {orderItem.product_title}
<TableCell className="min-w-[180px] px-6"> </p>
{order.location}
</TableCell> </TableCell>
)}
<TableCell className="min-w-[180px] px-6">
{isPackage ? (
<Trans
i18nKey={`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`}
/>
) : (
<Trans
i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`}
/>
)}
</TableCell>
<TableCell className="px-6 text-right"> <TableCell className="px-6 whitespace-nowrap">
<Button size="sm" onClick={openDetailedView}> {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
<Trans i18nKey="analysis-results:view" /> </TableCell>
</Button> {order.location && (
{isTtoservice && order.bookingCode && ( <TableCell className="min-w-[180px] px-6">
<Button {order.location}
size="sm" </TableCell>
className="bg-warning/90 hover:bg-warning mt-2 w-full"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)} )}
</TableCell> <TableCell className="min-w-[180px] px-6">
</TableRow> {isPackage ? (
))} <Trans
</TableBody> i18nKey={`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`}
/>
) : (
<Trans
i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`}
/>
)}
</TableCell>
<TableCell className="px-6 text-right">
<Button size="sm" onClick={openDetailedView}>
<Trans i18nKey="analysis-results:view" />
</Button>
{isTtoservice && order.bookingCode && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning mt-2 w-full"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{order?.bookingCode && order?.clinicId && ( {order?.bookingCode && order?.clinicId && (
<ConfirmationModal <ConfirmationModal
isOpen={isConfirmOpen} isOpen={isConfirmOpen}
@@ -141,6 +196,6 @@ export default function OrderItemsTable({
descriptionKey="orders:confirmBookingCancel.description" descriptionKey="orders:confirmBookingCancel.description"
/> />
)} )}
</Table> </>
); );
} }

View File

@@ -94,8 +94,8 @@ export function PersonalAccountDropdown({
const hasDoctorRole = const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor; personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor; return hasDoctorRole;
}, [personalAccountData, hasTotpFactor]); }, [personalAccountData]);
return ( return (
<DropdownMenu> <DropdownMenu>

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { AccountBalanceEntry } from '../../types/account-balance-entry'; import type { AccountBalanceEntry } from '../../types/account-balance-entry';
import { createAccountsApi } from '../api';
export type AccountBalanceSummary = { export type AccountBalanceSummary = {
totalBalance: number; totalBalance: number;
@@ -88,6 +89,11 @@ export class AccountBalanceService {
* Get balance summary for dashboard display * Get balance summary for dashboard display
*/ */
async getBalanceSummary(accountId: string): Promise<AccountBalanceSummary> { async getBalanceSummary(accountId: string): Promise<AccountBalanceSummary> {
const api = createAccountsApi(this.supabase);
const hasAccountTeamMembership =
await api.hasAccountTeamMembership(accountId);
const [balance, entries] = await Promise.all([ const [balance, entries] = await Promise.all([
this.getAccountBalance(accountId), this.getAccountBalance(accountId),
this.getAccountBalanceEntries(accountId, { limit: 5 }), this.getAccountBalanceEntries(accountId, { limit: 5 }),
@@ -113,6 +119,14 @@ export class AccountBalanceService {
const expiringSoon = const expiringSoon =
expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0; expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
if (!hasAccountTeamMembership) {
return {
totalBalance: 0,
expiringSoon,
recentEntries: entries.entries,
};
}
return { return {
totalBalance: balance, totalBalance: balance,
expiringSoon, expiringSoon,

View File

@@ -32,7 +32,7 @@ type Members =
Database['medreport']['Functions']['get_account_members']['Returns']; Database['medreport']['Functions']['get_account_members']['Returns'];
interface Permissions { interface Permissions {
canUpdateRole: (roleHierarchy: number) => boolean; canUpdateRole: boolean;
canRemoveFromAccount: (roleHierarchy: number) => boolean; canRemoveFromAccount: (roleHierarchy: number) => boolean;
canTransferOwnership: boolean; canTransferOwnership: boolean;
canUpdateBenefit: boolean; canUpdateBenefit: boolean;
@@ -67,11 +67,7 @@ export function AccountMembersTable({
const { t } = useTranslation('teams'); const { t } = useTranslation('teams');
const permissions = { const permissions = {
canUpdateRole: (targetRole: number) => { canUpdateRole: canManageRoles,
return (
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
);
},
canRemoveFromAccount: (targetRole: number) => { canRemoveFromAccount: (targetRole: number) => {
return ( return (
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole) isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
@@ -271,7 +267,6 @@ function ActionsDropdown({
const isPrimaryOwner = member.primary_owner_user_id === member.user_id; const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
const memberRoleHierarchy = member.role_hierarchy_level; const memberRoleHierarchy = member.role_hierarchy_level;
const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy);
const canRemoveFromAccount = const canRemoveFromAccount =
permissions.canRemoveFromAccount(memberRoleHierarchy); permissions.canRemoveFromAccount(memberRoleHierarchy);
@@ -279,9 +274,10 @@ function ActionsDropdown({
// if has no permission to update role, transfer ownership or remove from account // if has no permission to update role, transfer ownership or remove from account
// do not render the dropdown menu // do not render the dropdown menu
if ( if (
!canUpdateRole && !permissions.canUpdateRole &&
!permissions.canTransferOwnership && !permissions.canTransferOwnership &&
!canRemoveFromAccount !canRemoveFromAccount &&
!permissions.canUpdateBenefit
) { ) {
return null; return null;
} }
@@ -296,7 +292,7 @@ function ActionsDropdown({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<If condition={canUpdateRole && !isPrimaryOwner}> <If condition={permissions.canUpdateRole}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}> <DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
<Trans i18nKey={'teams:updateRole'} /> <Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -37,13 +37,10 @@ import { Trans } from '@kit/ui/trans';
import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions'; import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider'; import { RolesDataProvider } from './roles-data-provider';
type InviteModel = ReturnType<typeof createEmptyInviteModel>; type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = string;
/** /**
* The maximum number of invites that can be sent at once. * The maximum number of invites that can be sent at once.
* Useful to avoid spamming the server with too large payloads * Useful to avoid spamming the server with too large payloads
@@ -66,10 +63,7 @@ export function InviteMembersDialogContainer({
<Dialog open={isOpen} onOpenChange={setIsOpen} modal> <Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent onInteractOutside={(e) => e.preventDefault()}>
className="max-w-[800px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} /> <Trans i18nKey={'teams:inviteMembersHeading'} />
@@ -81,10 +75,9 @@ export function InviteMembersDialogContainer({
</DialogHeader> </DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}> <RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => ( {() => (
<InviteMembersForm <InviteMembersForm
pending={pending} pending={pending}
roles={roles}
onSubmit={(data) => { onSubmit={(data) => {
startTransition(() => { startTransition(() => {
const promise = createInvitationsAction({ const promise = createInvitationsAction({
@@ -111,12 +104,10 @@ export function InviteMembersDialogContainer({
function InviteMembersForm({ function InviteMembersForm({
onSubmit, onSubmit,
roles,
pending, pending,
}: { }: {
onSubmit: (data: { invitations: InviteModel[] }) => void; onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean; pending: boolean;
roles: string[];
}) { }) {
const { t } = useTranslation('teams'); const { t } = useTranslation('teams');
@@ -148,12 +139,11 @@ function InviteMembersForm({
const personalCodeInputName = const personalCodeInputName =
`invitations.${index}.personal_code` as const; `invitations.${index}.personal_code` as const;
const emailInputName = `invitations.${index}.email` as const; const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return ( return (
<div data-test={'invite-member-form-item'} key={field.id}> <div data-test="invite-member-form-item" key={field.id}>
<div className={'flex items-end gap-x-1 md:space-x-2'}> <div className="flex items-end gap-x-1 md:space-x-2">
<div className={'w-4/12'}> <div className="w-5/12">
<FormField <FormField
name={personalCodeInputName} name={personalCodeInputName}
render={({ field }) => { render={({ field }) => {
@@ -178,7 +168,7 @@ function InviteMembersForm({
}} }}
/> />
</div> </div>
<div className={'w-4/12'}> <div className={'w-5/12'}>
<FormField <FormField
name={emailInputName} name={emailInputName}
render={({ field }) => { render={({ field }) => {
@@ -205,37 +195,7 @@ function InviteMembersForm({
/> />
</div> </div>
<div className={'w-4/12'}> <div className={'flex w-1/12 items-end justify-end'}>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
</If>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0'}
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'flex w-[40px] items-end justify-end'}>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -303,5 +263,5 @@ function InviteMembersForm({
} }
function createEmptyInviteModel() { function createEmptyInviteModel() {
return { email: '', role: 'member' as Role, personal_code: '' }; return { email: '', personal_code: '' };
} }

View File

@@ -2,7 +2,6 @@ import { z } from 'zod';
const InviteSchema = z.object({ const InviteSchema = z.object({
email: z.string().email(), email: z.string().email(),
role: z.string().min(1).max(100),
personal_code: z personal_code: z
.string() .string()
.regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, { .regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {

View File

@@ -5,7 +5,6 @@ import { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { createNotificationsApi } from '@kit/notifications/api'; import { createNotificationsApi } from '@kit/notifications/api';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';

View File

@@ -191,7 +191,10 @@ class AccountInvitationsService {
const response = await this.client const response = await this.client
.schema('medreport') .schema('medreport')
.rpc('add_invitations_to_account', { .rpc('add_invitations_to_account', {
invitations, invitations: invitations.map((invitation) => ({
...invitation,
role: 'member',
})),
account_slug: accountSlug, account_slug: accountSlug,
}); });

View File

@@ -0,0 +1,15 @@
CREATE OR REPLACE FUNCTION medreport.is_doctor()
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM medreport.accounts
WHERE primary_owner_user_id = auth.uid()
AND application_role = 'doctor'
);
END;
$$;
grant execute on function medreport.is_doctor() to authenticated;

View File

@@ -0,0 +1,47 @@
drop policy "Allow select and update if user is account's primary owner" on medreport.company_params;
create policy "Allow select and update if user is account's HR"
on medreport.company_params
for all
using (
EXISTS (
SELECT 1
FROM medreport.accounts_memberships am
WHERE am.account_id = company_params.account_id
AND am.user_id = auth.uid()
AND am.account_role = 'owner'
)
)
with check (
EXISTS (
SELECT 1
FROM medreport.accounts_memberships am
WHERE am.account_id = company_params.account_id
AND am.user_id = auth.uid()
AND am.account_role = 'owner'
)
);
create or replace function medreport.clear_benefit_amount_on_employee_deletion()
returns trigger
language plpgsql
security definer
set search_path = medreport, public
as $$
begin
update medreport.account_balance_entries abe
set amount = 0
where abe.account_id = old.user_id
AND abe.entry_type = 'benefit';
return null;
end;
$$;
drop trigger if exists trigger_accounts_memberships_after_delete
on medreport.accounts_memberships;
create trigger trigger_accounts_memberships_after_delete
after delete on medreport.accounts_memberships
for each row
execute function medreport.clear_benefit_amount_on_employee_deletion();