Merge pull request #105 from MR-medreport/MED-97

feat(MED-97): create company benefits tables; company, superadmin view fixes
This commit is contained in:
2025-09-24 12:57:42 +03:00
committed by GitHub
36 changed files with 900 additions and 209 deletions

View File

@@ -0,0 +1,27 @@
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
import type { UserWorkspace } from '../../home/(user)/_lib/server/load-user-workspace';
export function AdminMenuNavigation(props: {
workspace: UserWorkspace;
}) {
const { accounts } = props.workspace;
return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={'/admin'} />
</div>
<div className="flex items-center justify-end gap-3">
<div>
<ProfileAccountDropdownContainer
accounts={accounts}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,72 +1,25 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard, Users } from 'lucide-react';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { adminNavigationConfig } from '@kit/shared/config';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
useSidebar,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
export function AdminSidebar({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const path = usePathname();
const { open } = useSidebar();
export function AdminSidebar() {
const collapsible = adminNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}>
<AppLogo href={'/admin'} className="max-w-full" compact={!open} />
<Sidebar collapsible={collapsible}>
<SidebarHeader className="h-24 justify-center">
<div className="mt-24 flex items-center">
<h5>Superadmin</h5>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton isActive={path === '/admin'} asChild>
<Link className={'flex gap-2.5'} href={'/admin'}>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path.includes('/admin/accounts')}
asChild
>
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarNavigation config={adminNavigationConfig} />
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer accounts={accounts} />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -4,6 +4,7 @@ import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getAccount } from '~/lib/services/account.service';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Params {
params: Promise<{
@@ -27,6 +28,6 @@ async function AccountPage(props: Params) {
return <AdminAccountPage account={account} />;
}
export default AdminGuard(AccountPage);
export default withI18n(AdminGuard(AccountPage));
const loadAccount = cache(getAccount);

View File

@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
interface SearchParams {
page?: string;
@@ -30,7 +31,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
return (
<>
<PageHeader description={<AppBreadcrumbs />}>
<PageHeader description={<AppBreadcrumbs />} title="Accounts">
<div className="flex justify-end gap-2">
<AdminCreateUserDialog>
<Button data-test="admin-create-user-button">
@@ -84,4 +85,4 @@ async function AccountsPage(props: AdminAccountsPageProps) {
);
}
export default AdminGuard(AccountsPage);
export default withI18n(AdminGuard(AccountsPage));

View File

@@ -6,6 +6,7 @@ import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMenuNavigation } from '~/admin/_components/admin-menu-navigation';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
@@ -21,19 +22,24 @@ export default function AdminLayout(props: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
return (
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<AdminSidebar accounts={workspace.accounts} />
</PageNavigation>
<Page style={'header'}>
<PageNavigation>
<AdminMenuNavigation workspace={workspace} />
</PageNavigation>
<PageMobileNavigation>
<AdminMobileNavigation />
</PageMobileNavigation>
<PageMobileNavigation>
<AdminMobileNavigation />
</PageMobileNavigation>
{props.children}
</Page>
</SidebarProvider>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<AdminSidebar />
</PageNavigation>
{props.children}
</Page>
</SidebarProvider>
</Page>
);
}

View File

@@ -1,11 +1,12 @@
import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
function AdminPage() {
return (
<>
<PageHeader description={`Super Admin`} />
<PageHeader title={`Super Admin`} />
<PageBody>
<AdminDashboard />
@@ -14,4 +15,4 @@ function AdminPage() {
);
}
export default AdminGuard(AdminPage);
export default withI18n(AdminGuard(AdminPage));

View File

@@ -1,12 +0,0 @@
import { z } from 'zod';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { SidebarNavigation } from '@kit/ui/shadcn-sidebar';
export function TeamAccountLayoutSidebarNavigation({
config,
}: React.PropsWithChildren<{
config: z.infer<typeof NavigationConfigSchema>;
}>) {
return <SidebarNavigation config={config} />;
}

View File

@@ -1,20 +1,12 @@
import type { User } from '@supabase/supabase-js';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { getTeamAccountSidebarConfig } from '@kit/shared/config';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
import { TeamAccountAccountsSelector } from '../_components/team-account-accounts-selector';
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
type AccountModel = {
label: string | null;
value: string | null;
@@ -26,14 +18,12 @@ export function TeamAccountLayoutSidebar(props: {
account: string;
accountId: string;
accounts: AccountModel[];
user: User;
}) {
return (
<SidebarContainer
account={props.account}
accountId={props.accountId}
accounts={props.accounts}
user={props.user}
/>
);
}
@@ -42,45 +32,27 @@ function SidebarContainer(props: {
account: string;
accountId: string;
accounts: AccountModel[];
user: User;
}) {
const { account, accounts, user } = props;
const userId = user.id;
const { account, accounts } = props;
const config = getTeamAccountSidebarConfig(account);
const collapsible = config.sidebarCollapsedStyle;
const selectedAccount = accounts.find(({ value }) => value === account);
const accountName = selectedAccount?.label || account;
return (
<Sidebar collapsible={collapsible}>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center justify-between gap-x-3">
<TeamAccountAccountsSelector
userId={userId}
selectedAccount={account}
accounts={accounts}
/>
<div className="group-data-[minimized=true]:hidden">
<TeamAccountNotifications
userId={userId}
accountId={props.accountId}
/>
</div>
<SidebarHeader className="h-24 justify-center">
<div className="mt-24 flex items-center">
<h5>{accountName}</h5>
</div>
</SidebarHeader>
<SidebarContent className={`mt-5 h-[calc(100%-160px)] overflow-y-auto`}>
<TeamAccountLayoutSidebarNavigation config={config} />
<SidebarContent>
<SidebarNavigation config={config} />
</SidebarContent>
<SidebarFooter>
<SidebarContent>
<ProfileAccountDropdownContainer
user={props.user}
accounts={accounts}
/>
</SidebarContent>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -20,7 +20,7 @@ const HealthBenefitFields = () => {
return (
<div className="flex flex-col gap-3">
<FormField
name="occurance"
name="occurrence"
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -30,20 +30,20 @@ const HealthBenefitFields = () => {
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue
placeholder={<Trans i18nKey="common:formField:occurance" />}
placeholder={<Trans i18nKey="common:formField:occurrence" />}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="yearly">
<Trans i18nKey="billing:occurance.yearly" />
<Trans i18nKey="billing:occurrence.yearly" />
</SelectItem>
<SelectItem value="quarterly">
<Trans i18nKey="billing:occurance.quarterly" />
<Trans i18nKey="billing:occurrence.quarterly" />
</SelectItem>
<SelectItem value="monthly">
<Trans i18nKey="billing:occurance.monthly" />
<Trans i18nKey="billing:occurrence.monthly" />
</SelectItem>
</SelectGroup>
</SelectContent>

View File

@@ -39,13 +39,13 @@ const HealthBenefitForm = ({
resolver: zodResolver(UpdateHealthBenefitSchema),
mode: 'onChange',
defaultValues: {
occurance: currentCompanyParams.benefit_occurance || 'yearly',
occurrence: currentCompanyParams.benefit_occurance || 'yearly',
amount: currentCompanyParams.benefit_amount || 0,
},
});
const isDirty = form.formState.isDirty;
const onSubmit = (data: { occurance: string; amount: number }) => {
const onSubmit = (data: { occurrence: string; amount: number }) => {
const promise = async () => {
setIsLoading(true);
try {
@@ -53,7 +53,7 @@ const HealthBenefitForm = ({
setCurrentCompanyParams((prev) => ({
...prev,
benefit_amount: data.amount,
benefit_occurance: data.occurance,
benefit_occurance: data.occurrence,
}));
} finally {
form.reset(data);
@@ -78,7 +78,7 @@ const HealthBenefitForm = ({
<h4>
<Trans
i18nKey="billing:pageTitle"
values={{ companyName: account.slug }}
values={{ companyName: account.name }}
/>
</h4>
<p className="text-muted-foreground text-sm">

View File

@@ -15,7 +15,6 @@ import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { TeamAccountLayoutMobileNavigation } from './_components/team-account-layout-mobile-navigation';
import { TeamAccountLayoutSidebar } from './_components/team-account-layout-sidebar';
import { TeamAccountNavigationMenu } from './_components/team-account-navigation-menu';
@@ -57,13 +56,12 @@ function SidebarLayout({
return (
<TeamAccountWorkspaceContextProvider value={data}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<Page style={'header'}>
<PageNavigation>
<TeamAccountLayoutSidebar
account={account}
accountId={data.account.id}
accounts={accounts}
user={data.user}
/>
</PageNavigation>
@@ -129,23 +127,8 @@ function HeaderLayout({
account={account}
accountId={data.account.id}
accounts={accounts}
user={data.user}
/>
</PageNavigation>
<PageMobileNavigation
className={'flex items-center justify-between'}
>
<AppLogo href={pathsConfig.app.home} />
<div className={'flex space-x-4'}>
<TeamAccountLayoutMobileNavigation
userId={data.user.id}
accounts={accounts}
account={account}
/>
</div>
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>

View File

@@ -22,7 +22,6 @@ import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
import { loadMembersPageData } from './_lib/server/members-page.loader';
@@ -56,8 +55,7 @@ async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) {
<>
<TeamAccountLayoutPageHeader
title={<Trans i18nKey={'common:routes.members'} />}
description={<AppBreadcrumbs />}
account={account.slug}
description={<AppBreadcrumbs values={{ [account.slug]: account.name }}/>}
/>
<PageBody>

View File

@@ -8,7 +8,6 @@ import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
// local imports
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
export const generateMetadata = async () => {
@@ -48,9 +47,8 @@ async function TeamAccountSettingsPage(props: TeamAccountSettingsPageProps) {
return (
<>
<TeamAccountLayoutPageHeader
account={account.slug}
title={<Trans i18nKey={'teams:settings.pageTitle'} />}
description={<AppBreadcrumbs />}
description={<AppBreadcrumbs values={{ [account.slug]: account.name }} />}
/>
<PageBody>

View File

@@ -1,9 +1,9 @@
import { z } from 'zod';
export const UpdateHealthBenefitSchema = z.object({
occurance: z
occurrence: z
.string({
error: 'Occurance is required',
error: 'Occurrence is required',
})
.nonempty(),
amount: z.number({ error: 'Amount is required' }),

View File

@@ -170,6 +170,7 @@ async function TeamAccountPage(props: {
<>
<PageHeader
className="border-b"
title={'Account'}
description={
<AppBreadcrumbs
values={{

View File

@@ -18,8 +18,8 @@ export async function AdminDashboard() {
' xl:grid-cols-4'
}
>
<Card>
<CardHeader>
<Card className="flex flex-col">
<CardHeader className="flex-1">
<CardTitle>Users</CardTitle>
<CardDescription>
@@ -34,8 +34,8 @@ export async function AdminDashboard() {
</CardContent>
</Card>
<Card>
<CardHeader>
<Card className="flex flex-col">
<CardHeader className="flex-1">
<CardTitle>Company Accounts</CardTitle>
<CardDescription>

View File

@@ -7,6 +7,7 @@ import { ColumnDef } from '@tanstack/react-table';
import { Database } from '@kit/supabase/database';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { formatDateAndTime } from '@kit/shared/utils';
type Memberships =
Database['medreport']['Functions']['get_account_members']['Returns'][number];
@@ -17,10 +18,6 @@ export function AdminMembersTable(props: { members: Memberships[] }) {
function getColumns(): ColumnDef<Memberships>[] {
return [
{
header: 'User ID',
accessorKey: 'user_id',
},
{
header: 'Name',
cell: ({ row }) => {
@@ -58,10 +55,16 @@ function getColumns(): ColumnDef<Memberships>[] {
{
header: 'Created At',
accessorKey: 'created_at',
cell: ({ row }) => {
return formatDateAndTime(row.original.created_at);
},
},
{
header: 'Updated At',
accessorKey: 'updated_at',
cell: ({ row }) => {
return formatDateAndTime(row.original.updated_at);
},
},
];
}

View File

@@ -267,7 +267,7 @@ export class TeamAccountsApi {
.schema('medreport')
.from('company_params')
.update({
benefit_occurance: data.occurance,
benefit_occurance: data.occurrence,
benefit_amount: data.amount,
updated_at: new Date().toISOString(),
})

View File

@@ -1,5 +1,5 @@
export interface UpdateHealthBenefitData {
accountId: string;
occurance: string;
occurrence: string;
amount: number;
}

View File

@@ -0,0 +1,33 @@
import { LayoutDashboard, Users } 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: 'Dashboard',
path: pathsConfig.app.admin,
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
},
{
label: 'Accounts',
path: `${pathsConfig.app.admin}/accounts`,
Icon: <Users className={iconClasses} />,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export const adminNavigationConfig = NavigationConfigSchema.parse({
routes,
style: 'custom',
sidebarCollapsed: false,
sidebarCollapsedStyle: 'icon',
});

View File

@@ -1,6 +1,7 @@
import appConfig from './app.config';
import authConfig from './auth.config';
import billingConfig from './billing.config';
import { adminNavigationConfig } from './admin-navigation.config';
import {
DynamicAuthConfig,
getCachedAuthConfig,
@@ -15,6 +16,7 @@ import {
} from './team-account-navigation.config';
export {
adminNavigationConfig,
appConfig,
authConfig,
billingConfig,

View File

@@ -7,7 +7,6 @@ const iconClasses = 'w-4';
const getRoutes = (account: string) => [
{
label: 'common:routes.application',
children: [
{
label: 'common:routes.dashboard',
@@ -15,12 +14,6 @@ const getRoutes = (account: string) => [
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
},
],
},
{
label: 'common:routes.settings',
collapsible: false,
children: [
{
label: 'common:routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),

View File

@@ -41,15 +41,19 @@ const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
);
CardHeader.displayName = 'CardHeader';
const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement> & { size?: 'h3' | 'h4' | 'h5' }> = ({
className,
size = 'h3',
...props
}) => (
<h3
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
}) => {
const Component = size;
return (
<Component
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
};
CardTitle.displayName = 'CardTitle';
const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({

View File

@@ -119,5 +119,25 @@
},
"cart": {
"label": "Cart ({{ items }})"
},
"pageTitle": "{{companyName}} budget",
"description": "Configure company budget..",
"saveChanges": "Save changes",
"healthBenefitForm": {
"title": "Health benefit form",
"description": "Company health benefit for employees",
"info": "* Taxes are added to the prices"
},
"occurrence": {
"yearly": "Yearly",
"quarterly": "Quarterly",
"monthly": "Monthly"
},
"expensesOverview": {
"title": "Expenses overview 2025",
"monthly": "Expense per employee per month *",
"yearly": "Maximum expense per employee per year *",
"total": "Maximum expense per {{employeeCount}} employee(s) per year *",
"sum": "Total"
}
}

View File

@@ -80,10 +80,11 @@
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile",
"application": "Application",
"pickTime": "Pick time",
"preferences": "Preferences",
"security": "Security"
"security": "Security",
"admin": "Admin",
"accounts": "Accounts"
},
"roles": {
"owner": {
@@ -124,7 +125,7 @@
"city": "City",
"weight": "Weight",
"height": "Height",
"occurance": "Support frequency",
"occurrence": "Support frequency",
"amount": "Amount",
"selectDate": "Select date"
},

View File

@@ -1,9 +1,9 @@
{
"home": {
"pageTitle": "Dashboard"
"pageTitle": "Company Dashboard"
},
"settings": {
"pageTitle": "Settings",
"pageTitle": "Company Settings",
"pageDescription": "Manage your Company details",
"teamLogo": "Company Logo",
"teamLogoDescription": "Update your company's logo to make it easier to identify",
@@ -13,10 +13,10 @@
"dangerZoneDescription": "This section contains actions that are irreversible"
},
"members": {
"pageTitle": "Members"
"pageTitle": "Company Members"
},
"billing": {
"pageTitle": "Billing"
"pageTitle": "Company Billing"
},
"yourTeams": "Your Companies ({{teamsCount}})",
"createTeam": "Create a Company",

View File

@@ -121,16 +121,16 @@
"label": "Cart ({{ items }})"
},
"pageTitle": "{{companyName}} eelarve",
"description": "Vali kalendrist sobiv kuupäev ja broneeri endale vastuvõtuaeg.",
"description": "Muuda ettevõtte eelarve seadistusi.",
"saveChanges": "Salvesta muudatused",
"healthBenefitForm": {
"title": "Tervisetoetuse vorm",
"description": "Ettevõtte Tervisekassa toetus töötajale",
"info": "* Hindadele lisanduvad riigipoolsed maksud"
},
"occurance": {
"occurrence": {
"yearly": "Kord aastas",
"quarterly": "kord kvartalis",
"quarterly": "Kord kvartalis",
"monthly": "Kord kuus"
},
"expensesOverview": {

View File

@@ -80,10 +80,11 @@
"dashboard": "Ülevaade",
"settings": "Seaded",
"profile": "Profiil",
"application": "Rakendus",
"pickTime": "Vali aeg",
"preferences": "Eelistused",
"security": "Turvalisus"
"security": "Turvalisus",
"admin": "Admin",
"accounts": "Kontod"
},
"roles": {
"owner": {
@@ -124,7 +125,7 @@
"city": "Linn",
"weight": "Kaal",
"height": "Pikkus",
"occurance": "Toetuse sagedus",
"occurrence": "Toetuse sagedus",
"amount": "Summa",
"selectDate": "Vali kuupäev"
},

View File

@@ -1,6 +1,6 @@
{
"home": {
"pageTitle": "Ülevaade",
"pageTitle": "Ettevõtte ülevaade",
"headerTitle": "{{companyName}} Tervisekassa kokkuvõte",
"healthDetails": "Ettevõtte terviseandmed",
"membersSettingsButtonTitle": "Halda töötajaid",
@@ -9,7 +9,7 @@
"membersBillingButtonDescription": "Vali kuidas soovid eelarvet töötajate vahel jagada."
},
"settings": {
"pageTitle": "Seaded",
"pageTitle": "Ettevõtte seaded",
"pageDescription": "Halda oma ettevõtte andmeid",
"teamLogo": "Ettevõtte logo",
"teamLogoDescription": "Uuenda oma ettevõtte logo, et seda oleks lihtsam tuvastada",
@@ -19,10 +19,10 @@
"dangerZoneDescription": "See osa sisaldab pöördumatuid toiminguid"
},
"members": {
"pageTitle": "Töötajad"
"pageTitle": "Ettevõtte töötajad"
},
"billing": {
"pageTitle": "Arveldamine"
"pageTitle": "Ettevõtte arveldamine"
},
"benefitStatistics": {
"budget": {

View File

@@ -121,14 +121,14 @@
"label": "Корзина ({{ items }})"
},
"pageTitle": "Бюджет {{companyName}}",
"description": "Выберите подходящую дату в календаре и запишитесь на прием.",
"description": "Измените настройки бюджета компании.",
"saveChanges": "Сохранить изменения",
"healthBenefitForm": {
"title": "Форма здоровья",
"description": "Поддержка сотрудника из корпоративного фонда здоровья",
"info": "* К ценам добавляются государственные налоги"
},
"occurance": {
"occurrence": {
"yearly": "Раз в год",
"quarterly": "Раз в квартал",
"monthly": "Раз в месяц"

View File

@@ -80,10 +80,11 @@
"dashboard": "Обзор",
"settings": "Настройки",
"profile": "Профиль",
"application": "Приложение",
"pickTime": "Выбрать время",
"preferences": "Предпочтения",
"security": "Безопасность"
"security": "Безопасность",
"admin": "Админ",
"accounts": "Аккаунты"
},
"roles": {
"owner": {
@@ -124,7 +125,7 @@
"city": "Город",
"weight": "Вес",
"height": "Рост",
"occurance": "Частота поддержки",
"occurrence": "Частота поддержки",
"amount": "Сумма",
"selectDate": "Выберите дату"
},

View File

@@ -1,6 +1,6 @@
{
"home": {
"pageTitle": "Обзор",
"pageTitle": "Обзор компании",
"headerTitle": "Обзор Tervisekassa {{companyName}}",
"healthDetails": "Данные о здоровье компании",
"membersSettingsButtonTitle": "Управление сотрудниками",
@@ -9,7 +9,7 @@
"membersBillingButtonDescription": "Выберите, как распределять бюджет между сотрудниками."
},
"settings": {
"pageTitle": "Настройки",
"pageTitle": "Настройки компании",
"pageDescription": "Управление данными вашей компании",
"teamLogo": "Логотип компании",
"teamLogoDescription": "Обновите логотип вашей компании для упрощения идентификации",
@@ -19,10 +19,10 @@
"dangerZoneDescription": "Этот раздел содержит действия, которые невозможно отменить"
},
"members": {
"pageTitle": "Сотрудники"
"pageTitle": "Сотрудники компании"
},
"billing": {
"pageTitle": "Биллинг"
"pageTitle": "Биллинг компании"
},
"benefitStatistics": {
"budget": {

View File

@@ -0,0 +1,239 @@
-- Create account_balance_entries table to track individual account balances
create table "medreport"."account_balance_entries" (
"id" uuid not null default gen_random_uuid(),
"account_id" uuid not null,
"amount" numeric not null,
"entry_type" text not null, -- 'benefit', 'purchase', 'refund', etc.
"description" text,
"source_company_id" uuid, -- Company that provided the benefit
"reference_id" text, -- Reference to related record (e.g., analysis order ID)
"created_at" timestamp with time zone not null default now(),
"created_by" uuid,
"expires_at" timestamp with time zone, -- When the balance expires (for benefits)
"is_active" boolean not null default true
);
-- Add constraints
alter table "medreport"."account_balance_entries" add constraint "account_balance_entries_pkey" primary key (id);
alter table "medreport"."account_balance_entries" add constraint "account_balance_entries_account_id_fkey"
foreign key (account_id) references medreport.accounts(id) on delete cascade;
alter table "medreport"."account_balance_entries" add constraint "account_balance_entries_source_company_id_fkey"
foreign key (source_company_id) references medreport.accounts(id) on delete set null;
-- Add indexes for performance
create index "ix_account_balance_entries_account_id" on "medreport"."account_balance_entries" (account_id);
create index "ix_account_balance_entries_entry_type" on "medreport"."account_balance_entries" (entry_type);
create index "ix_account_balance_entries_created_at" on "medreport"."account_balance_entries" (created_at);
create index "ix_account_balance_entries_active" on "medreport"."account_balance_entries" (is_active) where is_active = true;
-- Enable RLS
alter table "medreport"."account_balance_entries" enable row level security;
-- Create RLS policies
create policy "Users can view their own balance entries"
on "medreport"."account_balance_entries"
for select
using (
account_id in (
select account_id
from medreport.accounts_memberships
where user_id = auth.uid()
)
);
create policy "Service role can manage all balance entries"
on "medreport"."account_balance_entries"
for all
to service_role
using (true)
with check (true);
-- Grant permissions
grant select, insert, update, delete on table "medreport"."account_balance_entries" to authenticated, service_role;
-- Create function to get account balance
create or replace function medreport.get_account_balance(p_account_id uuid)
returns numeric
language plpgsql
security definer
as $$
declare
total_balance numeric := 0;
begin
select coalesce(sum(amount), 0)
into total_balance
from medreport.account_balance_entries
where account_id = p_account_id
and is_active = true
and (expires_at is null or expires_at > now());
return total_balance;
end;
$$;
-- Grant execute permission
grant execute on function medreport.get_account_balance(uuid) to authenticated, service_role;
-- Create function to distribute health benefits to all company members
create or replace function medreport.distribute_health_benefits(
p_company_id uuid,
p_benefit_amount numeric,
p_benefit_occurrence text default 'yearly'
)
returns void
language plpgsql
security definer
as $$
declare
member_record record;
benefit_entry_id uuid;
expires_date timestamp with time zone;
begin
-- Calculate expiration date based on occurrence
case p_benefit_occurrence
when 'yearly' then
expires_date := now() + interval '1 year';
when 'monthly' then
expires_date := now() + interval '1 month';
when 'quarterly' then
expires_date := now() + interval '3 months';
else
expires_date := now() + interval '1 year'; -- default to yearly
end case;
-- Get all personal accounts that are members of this company
for member_record in
select distinct a.id as personal_account_id
from medreport.accounts a
join medreport.accounts_memberships am on a.id = am.user_id
where am.account_id = p_company_id
and a.is_personal_account = true
loop
-- Insert balance entry for each personal account
insert into medreport.account_balance_entries (
account_id,
amount,
entry_type,
description,
source_company_id,
created_by,
expires_at
) values (
member_record.personal_account_id,
p_benefit_amount,
'benefit',
'Health benefit from company',
p_company_id,
auth.uid(),
expires_date
);
end loop;
end;
$$;
-- Grant execute permission
grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to authenticated, service_role;
-- Create trigger to automatically distribute benefits when company params are updated
create or replace function medreport.trigger_distribute_benefits()
returns trigger
language plpgsql
as $$
begin
-- Only distribute if benefit_amount is set and greater than 0
if new.benefit_amount is not null and new.benefit_amount > 0 then
-- Distribute benefits to all company members
perform medreport.distribute_health_benefits(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
end if;
return new;
end;
$$;
-- Create the trigger
create trigger trigger_distribute_benefits_on_update
after update on medreport.company_params
for each row
when (old.benefit_amount is distinct from new.benefit_amount or old.benefit_occurance is distinct from new.benefit_occurance)
execute function medreport.trigger_distribute_benefits();
-- Create function to consume balance (for purchases)
create or replace function medreport.consume_account_balance(
p_account_id uuid,
p_amount numeric,
p_description text,
p_reference_id text default null
)
returns boolean
language plpgsql
security definer
as $$
declare
current_balance numeric;
remaining_amount numeric := p_amount;
entry_record record;
consumed_amount numeric;
begin
-- Get current balance
current_balance := medreport.get_account_balance(p_account_id);
-- Check if sufficient balance
if current_balance < p_amount then
return false;
end if;
-- Consume balance using FIFO (First In, First Out) with expiration priority
for entry_record in
select id, amount
from medreport.account_balance_entries
where account_id = p_account_id
and is_active = true
and (expires_at is null or expires_at > now())
order by
case when expires_at is not null then expires_at else '9999-12-31'::timestamp end,
created_at
loop
if remaining_amount <= 0 then
exit;
end if;
consumed_amount := least(entry_record.amount, remaining_amount);
-- Update the entry
update medreport.account_balance_entries
set amount = amount - consumed_amount,
is_active = case when amount - consumed_amount <= 0 then false else true end
where id = entry_record.id;
remaining_amount := remaining_amount - consumed_amount;
end loop;
-- Record the consumption
insert into medreport.account_balance_entries (
account_id,
amount,
entry_type,
description,
reference_id,
created_by
) values (
p_account_id,
-p_amount,
'purchase',
p_description,
p_reference_id,
auth.uid()
);
return true;
end;
$$;
-- Grant execute permission
grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to authenticated, service_role;

View File

@@ -0,0 +1,303 @@
-- Fix the trigger to actually call the distribution function
-- First, drop the existing trigger and function
drop trigger if exists trigger_distribute_benefits_on_update on medreport.company_params;
drop function if exists medreport.trigger_distribute_benefits();
-- Create a new trigger function that actually distributes benefits
create or replace function medreport.trigger_distribute_benefits()
returns trigger
language plpgsql
as $$
begin
-- Only distribute if benefit_amount is set and greater than 0
if new.benefit_amount is not null and new.benefit_amount > 0 then
-- Distribute benefits to all company members
perform medreport.distribute_health_benefits(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
end if;
return new;
end;
$$;
-- Recreate the trigger
create trigger trigger_distribute_benefits_on_update
after update on medreport.company_params
for each row
when (old.benefit_amount is distinct from new.benefit_amount or old.benefit_occurance is distinct from new.benefit_occurance)
execute function medreport.trigger_distribute_benefits();
-- Create a table to track periodic benefit distributions
create table "medreport"."benefit_distribution_schedule" (
"id" uuid not null default gen_random_uuid(),
"company_id" uuid not null,
"benefit_amount" numeric not null,
"benefit_occurrence" text not null,
"last_distributed_at" timestamp with time zone,
"next_distribution_at" timestamp with time zone not null,
"is_active" boolean not null default true,
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now()
);
-- Add constraints
alter table "medreport"."benefit_distribution_schedule" add constraint "benefit_distribution_schedule_pkey" primary key (id);
alter table "medreport"."benefit_distribution_schedule" add constraint "benefit_distribution_schedule_company_id_fkey"
foreign key (company_id) references medreport.accounts(id) on delete cascade;
-- Add unique constraint on company_id for upsert functionality
create unique index "ix_benefit_distribution_schedule_company_id_unique"
on "medreport"."benefit_distribution_schedule" (company_id)
where is_active = true;
-- Add indexes
create index "ix_benefit_distribution_schedule_next_distribution" on "medreport"."benefit_distribution_schedule" (next_distribution_at) where is_active = true;
create index "ix_benefit_distribution_schedule_company_id" on "medreport"."benefit_distribution_schedule" (company_id);
-- Enable RLS
alter table "medreport"."benefit_distribution_schedule" enable row level security;
-- Create RLS policies
create policy "Service role can manage all distribution schedules"
on "medreport"."benefit_distribution_schedule"
for all
to service_role
using (true)
with check (true);
create policy "Users can view distribution schedules for their companies"
on "medreport"."benefit_distribution_schedule"
for select
to authenticated
using (
company_id in (
select account_id
from medreport.accounts_memberships
where user_id = auth.uid()
)
);
-- Grant permissions
grant select, insert, update, delete on table "medreport"."benefit_distribution_schedule" to service_role, authenticated;
-- Function to calculate next distribution date
create or replace function medreport.calculate_next_distribution_date(
p_occurrence text,
p_current_date timestamp with time zone default now()
)
returns timestamp with time zone
language plpgsql
as $$
declare
next_date timestamp with time zone;
current_year integer;
current_month integer;
current_quarter integer;
begin
case p_occurrence
when 'yearly' then
-- First day of next year
current_year := extract(year from p_current_date);
next_date := make_date(current_year + 1, 1, 1);
return next_date;
when 'monthly' then
-- First day of next month
current_year := extract(year from p_current_date);
current_month := extract(month from p_current_date);
if current_month = 12 then
next_date := make_date(current_year + 1, 1, 1);
else
next_date := make_date(current_year, current_month + 1, 1);
end if;
return next_date;
when 'quarterly' then
-- First day of next quarter
current_year := extract(year from p_current_date);
current_month := extract(month from p_current_date);
current_quarter := ((current_month - 1) / 3) + 1;
if current_quarter = 4 then
next_date := make_date(current_year + 1, 1, 1);
else
next_date := make_date(current_year, (current_quarter * 3) + 1, 1);
end if;
return next_date;
else
-- Default to yearly
current_year := extract(year from p_current_date);
next_date := make_date(current_year + 1, 1, 1);
return next_date;
end case;
end;
$$;
-- Grant execute permission
grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to authenticated, service_role;
-- Function to process periodic benefit distributions
create or replace function medreport.process_periodic_benefit_distributions()
returns void
language plpgsql
as $$
declare
schedule_record record;
next_distribution_date timestamp with time zone;
begin
-- Get all active schedules that are due for distribution
for schedule_record in
select *
from medreport.benefit_distribution_schedule
where is_active = true
and next_distribution_at <= now()
loop
-- Distribute benefits
perform medreport.distribute_health_benefits(
schedule_record.company_id,
schedule_record.benefit_amount,
schedule_record.benefit_occurrence
);
-- Calculate next distribution date
next_distribution_date := medreport.calculate_next_distribution_date(
schedule_record.benefit_occurrence,
now()
);
-- Update the schedule
update medreport.benefit_distribution_schedule
set
last_distributed_at = now(),
next_distribution_at = next_distribution_date,
updated_at = now()
where id = schedule_record.id;
end loop;
end;
$$;
-- Grant execute permission
grant execute on function medreport.process_periodic_benefit_distributions() to service_role;
-- Function to create or update a benefit distribution schedule
create or replace function medreport.upsert_benefit_distribution_schedule(
p_company_id uuid,
p_benefit_amount numeric,
p_benefit_occurrence text
)
returns void
language plpgsql
as $$
declare
next_distribution_date timestamp with time zone;
existing_record_id uuid;
begin
-- Calculate next distribution date
next_distribution_date := medreport.calculate_next_distribution_date(p_benefit_occurrence);
-- Check if there's an existing record for this company
select id into existing_record_id
from medreport.benefit_distribution_schedule
where company_id = p_company_id
limit 1;
if existing_record_id is not null then
-- Update existing record
update medreport.benefit_distribution_schedule
set
benefit_amount = p_benefit_amount,
benefit_occurrence = p_benefit_occurrence,
next_distribution_at = next_distribution_date,
is_active = true,
updated_at = now()
where id = existing_record_id;
else
-- Insert new record
insert into medreport.benefit_distribution_schedule (
company_id,
benefit_amount,
benefit_occurrence,
next_distribution_at
) values (
p_company_id,
p_benefit_amount,
p_benefit_occurrence,
next_distribution_date
);
end if;
end;
$$;
-- Grant execute permission
grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to service_role, authenticated;
grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to service_role, authenticated;
grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to service_role, authenticated;
-- Also grant permissions to the original functions from the first migration
grant execute on function medreport.get_account_balance(uuid) to service_role, authenticated;
grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to service_role, authenticated;
-- Update the trigger to also create/update the distribution schedule
create or replace function medreport.trigger_distribute_benefits()
returns trigger
language plpgsql
security definer
as $$
begin
-- Only distribute if benefit_amount is set and greater than 0
if new.benefit_amount is not null and new.benefit_amount > 0 then
-- Distribute benefits to all company members immediately
perform medreport.distribute_health_benefits(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
-- Create or update the distribution schedule for future distributions
perform medreport.upsert_benefit_distribution_schedule(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
else
-- If benefit_amount is 0 or null, deactivate the schedule
update medreport.benefit_distribution_schedule
set is_active = false, updated_at = now()
where company_id = new.account_id;
end if;
return new;
end;
$$;
-- Create a function to manually trigger benefit distribution (for testing)
create or replace function medreport.trigger_benefit_distribution(p_company_id uuid)
returns void
language plpgsql
as $$
declare
company_params record;
begin
-- Get company params
select * into company_params
from medreport.company_params
where account_id = p_company_id;
if found and company_params.benefit_amount > 0 then
-- Distribute benefits
perform medreport.distribute_health_benefits(
p_company_id,
company_params.benefit_amount,
coalesce(company_params.benefit_occurance, 'yearly')
);
end if;
end;
$$;
-- Grant execute permission
grant execute on function medreport.trigger_benefit_distribution(uuid) to service_role, authenticated;
grant execute on function medreport.trigger_distribute_benefits() to service_role, authenticated;

View File

@@ -0,0 +1,48 @@
-- Quick fix for RLS permissions issue
-- Run this immediately to fix the permission denied error
-- Grant execute permissions to authenticated users for all benefit distribution functions
grant execute on function medreport.get_account_balance(uuid) to authenticated;
grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to authenticated;
grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to authenticated;
grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to authenticated;
grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to authenticated;
grant execute on function medreport.trigger_benefit_distribution(uuid) to authenticated;
grant execute on function medreport.trigger_distribute_benefits() to authenticated;
grant execute on function medreport.process_periodic_benefit_distributions() to authenticated;
-- Also ensure the trigger function has security definer
create or replace function medreport.trigger_distribute_benefits()
returns trigger
language plpgsql
security definer
as $$
begin
-- Only distribute if benefit_amount is set and greater than 0
if new.benefit_amount is not null and new.benefit_amount > 0 then
-- Distribute benefits to all company members immediately
perform medreport.distribute_health_benefits(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
-- Create or update the distribution schedule for future distributions
perform medreport.upsert_benefit_distribution_schedule(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
else
-- If benefit_amount is 0 or null, deactivate the schedule
update medreport.benefit_distribution_schedule
set is_active = false, updated_at = now()
where company_id = new.account_id;
end if;
return new;
end;
$$;
-- Grant execute permission to the updated trigger function
grant execute on function medreport.trigger_distribute_benefits() to authenticated, service_role;

View File

@@ -0,0 +1,114 @@
-- Fix upsert function and RLS permissions
-- Run this to fix the ON CONFLICT error and 403 permission error
-- 1. Fix the upsert function to not use ON CONFLICT
create or replace function medreport.upsert_benefit_distribution_schedule(
p_company_id uuid,
p_benefit_amount numeric,
p_benefit_occurrence text
)
returns void
language plpgsql
as $$
declare
next_distribution_date timestamp with time zone;
existing_record_id uuid;
begin
-- Calculate next distribution date
next_distribution_date := medreport.calculate_next_distribution_date(p_benefit_occurrence);
-- Check if there's an existing record for this company
select id into existing_record_id
from medreport.benefit_distribution_schedule
where company_id = p_company_id
limit 1;
if existing_record_id is not null then
-- Update existing record
update medreport.benefit_distribution_schedule
set
benefit_amount = p_benefit_amount,
benefit_occurrence = p_benefit_occurrence,
next_distribution_at = next_distribution_date,
is_active = true,
updated_at = now()
where id = existing_record_id;
else
-- Insert new record
insert into medreport.benefit_distribution_schedule (
company_id,
benefit_amount,
benefit_occurrence,
next_distribution_at
) values (
p_company_id,
p_benefit_amount,
p_benefit_occurrence,
next_distribution_date
);
end if;
end;
$$;
-- 2. Add RLS policy for authenticated users to read distribution schedules
create policy "Users can view distribution schedules for their companies"
on "medreport"."benefit_distribution_schedule"
for select
to authenticated
using (
company_id in (
select account_id
from medreport.accounts_memberships
where user_id = auth.uid()
)
);
-- 3. Grant permissions to authenticated users
grant select, insert, update, delete on table "medreport"."benefit_distribution_schedule" to authenticated;
-- 4. Grant execute permissions to all functions
grant execute on function medreport.get_account_balance(uuid) to authenticated;
grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to authenticated;
grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to authenticated;
grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to authenticated;
grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to authenticated;
grant execute on function medreport.trigger_benefit_distribution(uuid) to authenticated;
grant execute on function medreport.trigger_distribute_benefits() to authenticated;
grant execute on function medreport.process_periodic_benefit_distributions() to authenticated;
-- 5. Ensure trigger function has security definer
create or replace function medreport.trigger_distribute_benefits()
returns trigger
language plpgsql
security definer
as $$
begin
-- Only distribute if benefit_amount is set and greater than 0
if new.benefit_amount is not null and new.benefit_amount > 0 then
-- Distribute benefits to all company members immediately
perform medreport.distribute_health_benefits(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
-- Create or update the distribution schedule for future distributions
perform medreport.upsert_benefit_distribution_schedule(
new.account_id,
new.benefit_amount,
coalesce(new.benefit_occurance, 'yearly')
);
else
-- If benefit_amount is 0 or null, deactivate the schedule
update medreport.benefit_distribution_schedule
set is_active = false, updated_at = now()
where company_id = new.account_id;
end if;
return new;
end;
$$;
-- 6. Grant execute permission to the updated trigger function
grant execute on function medreport.trigger_distribute_benefits() to authenticated, service_role;