Autosave: 20260404-032450

This commit is contained in:
Flatlogic Bot 2026-04-04 03:24:50 +00:00
parent 9ca9df3974
commit b4ba3c2646
18 changed files with 1970 additions and 2077 deletions

View File

@ -0,0 +1,104 @@
import React, { ReactNode } from 'react'
import BaseButton from './BaseButton'
type ConnectedEntityCardDetail = {
label: string
value?: ReactNode
}
type ConnectedEntityCardAction = {
href: string
label: string
color?: string
outline?: boolean
small?: boolean
className?: string
}
type ConnectedEntityCardProps = {
entityLabel: string
title?: ReactNode
titleFallback?: string
details?: ConnectedEntityCardDetail[]
actions?: ConnectedEntityCardAction[]
helperText?: ReactNode
className?: string
}
const ConnectedEntityCard = ({
entityLabel,
title,
titleFallback = 'Unnamed record',
details = [],
actions = [],
helperText,
className = '',
}: ConnectedEntityCardProps) => {
const visibleDetails = details.filter((detail) => {
const value = detail?.value
return value !== undefined && value !== null && value !== ''
})
return (
<div
className={`rounded-xl border border-blue-100 bg-white p-4 shadow-sm dark:border-blue-950/60 dark:bg-dark-900 ${className}`.trim()}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
{entityLabel}
</span>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Linked record
</span>
</div>
<p className="mt-3 break-words text-base font-semibold text-gray-800 dark:text-gray-100">
{title || titleFallback}
</p>
{visibleDetails.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{visibleDetails.map((detail) => (
<span
key={`${detail.label}-${String(detail.value)}`}
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
>
<span className="font-semibold text-gray-900 dark:text-gray-100">{detail.label}:</span>{' '}
{detail.value}
</span>
))}
</div>
) : null}
</div>
{actions.length ? (
<div className="flex flex-col gap-2 sm:items-end">
{actions.map((action) => (
<BaseButton
key={`${action.href}-${action.label}`}
href={action.href}
color={action.color || 'info'}
outline={action.outline}
small={action.small ?? true}
label={action.label}
className={action.className || 'w-full sm:w-auto'}
/>
))}
{helperText ? (
<p className="text-xs font-medium text-blue-700 dark:text-blue-200 sm:max-w-[16rem] sm:text-right">
{helperText}
</p>
) : null}
</div>
) : null}
</div>
</div>
)
}
export type { ConnectedEntityCardAction, ConnectedEntityCardDetail }
export default ConnectedEntityCard

View File

@ -0,0 +1,106 @@
import React, { ReactNode } from 'react'
import BaseButton from './BaseButton'
type ConnectedEntityAction = {
href: string
label: string
color?: string
outline?: boolean
small?: boolean
className?: string
}
type ConnectedEntityNoticeProps = {
title?: string
description?: ReactNode
actions?: ConnectedEntityAction[]
contextLabel?: string
contextHref?: string
contextActionLabel?: string
className?: string
compact?: boolean
}
const ConnectedEntityNotice = ({
title = 'Connected entities',
description,
actions = [],
contextLabel,
contextHref,
contextActionLabel = 'Open',
className = '',
compact = false,
}: ConnectedEntityNoticeProps) => {
if (compact) {
if (!contextLabel) {
return null
}
return (
<div
className={`mb-4 inline-flex max-w-full flex-wrap items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100 ${className}`.trim()}
>
<span className="font-semibold uppercase tracking-[0.18em]">Opened from</span>
<span className="max-w-full break-words font-medium">{contextLabel}</span>
{contextHref ? (
<BaseButton
href={contextHref}
color="white"
outline
small
label={contextActionLabel}
className="!px-2.5 !py-0.5"
/>
) : null}
</div>
)
}
return (
<div
className={`mb-6 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100 ${className}`.trim()}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-semibold">{title}</p>
{contextLabel ? (
<div className="mt-2 inline-flex max-w-full flex-wrap items-center gap-2 rounded-full border border-blue-200/80 bg-white/70 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100">
<span className="font-semibold uppercase tracking-[0.18em]">Opened from</span>
<span className="break-words">{contextLabel}</span>
{contextHref ? (
<BaseButton
href={contextHref}
color="white"
outline
small
label={contextActionLabel}
className="!px-2.5 !py-0.5"
/>
) : null}
</div>
) : null}
{description ? <div className="mt-2 leading-6">{description}</div> : null}
</div>
{actions.length ? (
<div className="flex flex-col gap-2 sm:items-end">
{actions.map((action) => (
<BaseButton
key={`${action.href}-${action.label}`}
href={action.href}
color={action.color || 'info'}
outline={action.outline}
small={action.small ?? true}
label={action.label}
className={action.className || 'w-full sm:w-auto'}
/>
))}
</div>
) : null}
</div>
</div>
)
}
export type { ConnectedEntityAction }
export default ConnectedEntityNotice

View File

@ -1,24 +1,22 @@
import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import LinkedTenantsPreview from './LinkedTenantsPreview'
import ListActionsPopover from '../ListActionsPopover'
import LoadingSpinner from '../LoadingSpinner'
import { Pagination } from '../Pagination'
import { useAppSelector } from '../../stores/hooks'
import { hasPermission } from '../../helpers/userPermissions'
import { loadLinkedTenantSummaries, OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Props = {
organizations: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
organizations: any[]
loading: boolean
onDelete: (id: string) => void
currentPage: number
numPages: number
onPageChange: (page: number) => void
}
const CardOrganizations = ({
organizations,
@ -28,84 +26,102 @@ const CardOrganizations = ({
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
const bgColor = useAppSelector((state) => state.style.cardsColor)
const darkMode = useAppSelector((state) => state.style.darkMode)
const corners = useAppSelector((state) => state.style.corners)
const focusRing = useAppSelector((state) => state.style.focusRingColor)
const currentUser = useAppSelector((state) => state.auth.currentUser)
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({})
useEffect(() => {
if (!Array.isArray(organizations) || !organizations.length) {
setLinkedTenantSummaries({})
return
}
let isActive = true
loadLinkedTenantSummaries(organizations.map((item: any) => item?.id))
.then((summaries) => {
if (isActive) {
setLinkedTenantSummaries(summaries)
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization cards:', error)
if (isActive) {
setLinkedTenantSummaries({})
}
})
return () => {
isActive = false
}
}, [organizations])
return (
<div className={'p-4'}>
<div className='p-4'>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && organizations.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'>
{!loading &&
organizations.map((item) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
}`}
>
<div
className={`relative flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6 ${bgColor} dark:bg-dark-800`}
>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='line-clamp-1 text-lg font-bold leading-6'>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
pathView={`/organizations/organizations-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
<div className='ml-auto'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
pathView={`/organizations/organizations-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='h-64 overflow-y-auto divide-y divide-gray-600 px-6 py-4 text-sm leading-6 dark:divide-dark-700'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.name}</div>
</dd>
</div>
</dl>
</li>
))}
<div className='flex flex-col gap-y-3 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Linked tenants</dt>
<dd>
<LinkedTenantsPreview
summary={linkedTenantSummaries[item.id]}
emptyMessage='This organization is not linked to a tenant yet.'
/>
</dd>
</div>
</dl>
</li>
))}
{!loading && organizations.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
<div className='col-span-full flex h-40 items-center justify-center'>
<p>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
<div className='my-6 flex items-center justify-center'>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
</div>
</div>
);
};
)
}
export default CardOrganizations;
export default CardOrganizations

View File

@ -0,0 +1,51 @@
import React from 'react'
import {
emptyOrganizationTenantSummary,
OrganizationTenantSummary,
} from '../../helpers/organizationTenants'
type Props = {
summary?: OrganizationTenantSummary
emptyMessage?: string
compact?: boolean
}
const LinkedTenantsPreview = ({
summary = emptyOrganizationTenantSummary,
emptyMessage = 'No linked tenants',
compact = false,
}: Props) => {
const tenants = Array.isArray(summary?.rows) ? summary.rows : []
if (!summary?.count) {
return <div className='text-sm text-gray-500 dark:text-dark-600'>{emptyMessage}</div>
}
const preview = compact ? tenants.slice(0, 2) : tenants.slice(0, 3)
const remainingCount = Math.max(summary.count - preview.length, 0)
return (
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{summary.count} linked
</span>
{preview.map((tenant: any) => (
<span
key={tenant.id || tenant.name}
className='max-w-full truncate rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={tenant.name}
>
{tenant.name || 'Unnamed tenant'}
</span>
))}
{remainingCount > 0 ? (
<span className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
+{remainingCount} more
</span>
) : null}
</div>
)
}
export default LinkedTenantsPreview

View File

@ -13,6 +13,7 @@ import {
GridColDef,
} from '@mui/x-data-grid';
import {loadColumns} from "./configureOrganizationsCols";
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles";
@ -33,6 +34,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState<GridColDef[]>([]);
const [selectedRows, setSelectedRows] = useState([]);
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
const [sortModel, setSortModel] = useState([
{
field: '',
@ -170,17 +172,44 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
loadData(page);
setCurrentPage(page);
};
useEffect(() => {
const organizationRows = Array.isArray(organizations) ? organizations : [];
useEffect(() => {
if (!currentUser) return;
if (!organizationRows.length) {
setLinkedTenantSummaries({});
return;
}
loadColumns(
handleDeleteModalAction,
`organizations`,
currentUser,
).then((newCols) => setColumns(newCols));
}, [currentUser]);
let isActive = true;
loadLinkedTenantSummaries(organizationRows.map((item: any) => item?.id))
.then((summaries) => {
if (isActive) {
setLinkedTenantSummaries(summaries);
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization table:', error);
if (isActive) {
setLinkedTenantSummaries({});
}
});
return () => {
isActive = false;
};
}, [organizations]);
useEffect(() => {
if (!currentUser) return;
loadColumns(
handleDeleteModalAction,
`organizations`,
currentUser,
linkedTenantSummaries,
).then((newCols) => setColumns(newCols));
}, [currentUser, linkedTenantSummaries]);
@ -211,7 +240,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={64}
rowHeight={78}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => `datagrid--row`}

View File

@ -1,83 +1,69 @@
import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios';
import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover';
import React from 'react'
import { GridRowParams } from '@mui/x-data-grid'
import {hasPermission} from "../../helpers/userPermissions";
import LinkedTenantsPreview from './LinkedTenantsPreview'
import ListActionsPopover from '../ListActionsPopover'
import { hasPermission } from '../../helpers/userPermissions'
import { OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Params = (id: string) => void;
type Params = (id: string) => void
export const loadColumns = async (
onDelete: Params,
entityName: string,
user
onDelete: Params,
_entityName: string,
user,
linkedTenantSummaries: OrganizationTenantSummaryMap = {},
) => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
]
},
},
];
};
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 180,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'linkedTenants',
headerName: 'Linked tenants',
flex: 1.2,
minWidth: 220,
filterable: false,
sortable: false,
editable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
renderCell: (params: any) => (
<div className='flex min-h-[52px] items-center py-2'>
<LinkedTenantsPreview
summary={linkedTenantSummaries[params?.row?.id]}
compact
emptyMessage='Not linked yet'
/>
</div>
),
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
],
},
]
}

View File

@ -55,12 +55,17 @@ const CardTenants = ({
}`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
<div className={`flex items-start ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<div className='min-w-0'>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
<p className='mt-1 text-xs text-gray-500 dark:text-dark-600'>
{Array.isArray(item.organizations) && item.organizations.length
? `${item.organizations.length} linked organization${item.organizations.length === 1 ? '' : 's'}`
: 'No linked organizations'}
</p>
</div>
<div className='ml-auto '>
<ListActionsPopover
@ -161,12 +166,27 @@ const CardTenants = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Organizations</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}
</div>
<div className='flex flex-col gap-y-3 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Linked organizations</dt>
<dd className='flex flex-wrap items-center gap-2'>
{Array.isArray(item.organizations) && item.organizations.length ? (
<>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{item.organizations.length} linked
</span>
{item.organizations.map((organization: any) => (
<span
key={organization.id || organization.name}
className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name}
>
{organization.name}
</span>
))}
</>
) : (
<div className='font-medium text-gray-500 dark:text-dark-600'>No linked organizations</div>
)}
</dd>
</div>

View File

@ -211,7 +211,7 @@ const TableSampleTenants = ({ filterItems, setFilterItems, filters, showGrid })
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={64}
rowHeight={72}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => `datagrid--row`}

View File

@ -149,9 +149,9 @@ export const loadColumns = async (
{
field: 'organizations',
headerName: 'Organizations',
flex: 1,
minWidth: 120,
headerName: 'Linked organizations',
flex: 1.2,
minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
@ -161,6 +161,42 @@ export const loadColumns = async (
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.organizationsManyListFormatter(value).join(', '),
renderCell: (params: any) => {
const organizations = Array.isArray(params.value) ? params.value : [];
if (!organizations.length) {
return (
<div className='py-2 text-sm text-gray-500 dark:text-dark-600'>
No linked organizations
</div>
);
}
const preview = organizations.slice(0, 2);
const remainingCount = organizations.length - preview.length;
return (
<div className='flex min-w-0 flex-wrap items-center gap-1 py-2'>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{organizations.length} linked
</span>
{preview.map((organization: any) => (
<span
key={organization.id || organization.name}
className='max-w-full truncate rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name}
>
{organization.name}
</span>
))}
{remainingCount > 0 ? (
<span className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
+{remainingCount} more
</span>
) : null}
</div>
);
},
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'organizations'}/>
),

View File

@ -48,14 +48,46 @@ const hiddenFieldsByEntityAndLane: Record<string, Record<RoleLane, Set<string>>>
const isRangeFilter = (filter?: FilterDefinition) => Boolean(filter?.number || filter?.date)
const getFilterDisplayLabel = (filter?: FilterDefinition) => {
const rawLabel = `${filter?.label ?? filter?.title ?? ""}`.trim()
const getHumanizedFieldName = (value?: string) => {
if (!value) {
return ''
}
if (!rawLabel) {
return value
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
const cleanupFilterDisplayLabel = (value: string) => {
if (!value) {
return 'this field'
}
return rawLabel.charAt(0).toLowerCase() + rawLabel.slice(1)
const cleanedValue = value
.replace(/\bids\b/gi, '')
.replace(/\bid\b$/i, '')
.replace(/\bat\b$/i, '')
.replace(/\burl\b/gi, 'URL')
.replace(/\bapi\b/gi, 'API')
.replace(/\bcheck in\b/gi, 'check-in')
.replace(/\bcheck out\b/gi, 'check-out')
.replace(/\s+/g, ' ')
.trim()
if (!cleanedValue) {
return 'this field'
}
return cleanedValue.charAt(0).toLowerCase() + cleanedValue.slice(1)
}
const getFilterDisplayLabel = (filter?: FilterDefinition) => {
const rawTitle = getHumanizedFieldName(filter?.title)
const rawLabel = getHumanizedFieldName(filter?.label)
const displaySource = rawTitle || rawLabel
return cleanupFilterDisplayLabel(displaySource)
}
export const getHiddenFieldsForRole = (entityName: string, roleLane: RoleLane) => {

View File

@ -0,0 +1,199 @@
import axios from 'axios'
export type LinkedTenantRecord = {
id: string
name?: string
slug?: string
primary_domain?: string
timezone?: string
default_currency?: string
is_active?: boolean
}
export type OrganizationTenantSummary = {
rows: LinkedTenantRecord[]
count: number
}
export type OrganizationTenantSummaryMap = Record<string, OrganizationTenantSummary>
export const emptyOrganizationTenantSummary: OrganizationTenantSummary = {
rows: [],
count: 0,
}
const normalizeTenantRows = (rows: any): LinkedTenantRecord[] => {
if (!Array.isArray(rows)) {
return []
}
return rows.map((row: any) => ({
id: row?.id,
name: row?.name,
slug: row?.slug,
primary_domain: row?.primary_domain,
timezone: row?.timezone,
default_currency: row?.default_currency,
is_active: row?.is_active,
}))
}
export const getTenantSetupHref = (organizationId?: string, organizationName?: string) => {
if (!organizationId) {
return '/tenants/tenants-new'
}
const params = new URLSearchParams({ organizationId })
if (organizationName) {
params.set('organizationName', organizationName)
}
return `/tenants/tenants-new?${params.toString()}`
}
export const getTenantViewHref = (tenantId?: string, organizationId?: string, organizationName?: string) => {
if (!tenantId) {
return '/tenants/tenants-list'
}
const params = new URLSearchParams({ id: tenantId })
if (organizationId) {
params.set('organizationId', organizationId)
}
if (organizationName) {
params.set('organizationName', organizationName)
}
return `/tenants/tenants-view/?${params.toString()}`
}
export const getTenantManageHref = (
tenantId?: string,
organizationId?: string,
organizationName?: string,
) => {
if (!tenantId) {
return '/tenants/tenants-list'
}
const params = new URLSearchParams({ id: tenantId })
if (organizationId) {
params.set('organizationId', organizationId)
}
if (organizationName) {
params.set('organizationName', organizationName)
}
return `/tenants/tenants-edit/?${params.toString()}`
}
export const getOrganizationViewHref = (organizationId?: string, tenantId?: string, tenantName?: string) => {
if (!organizationId) {
return '/organizations/organizations-list'
}
const params = new URLSearchParams({ id: organizationId })
if (tenantId) {
params.set('tenantId', tenantId)
}
if (tenantName) {
params.set('tenantName', tenantName)
}
return `/organizations/organizations-view/?${params.toString()}`
}
export const getOrganizationManageHref = (
organizationId?: string,
tenantId?: string,
tenantName?: string,
) => {
if (!organizationId) {
return '/organizations/organizations-list'
}
const params = new URLSearchParams({ id: organizationId })
if (tenantId) {
params.set('tenantId', tenantId)
}
if (tenantName) {
params.set('tenantName', tenantName)
}
return `/organizations/organizations-edit/?${params.toString()}`
}
export const mergeEntityOptions = (existingOptions: any[] = [], nextOptions: any[] = []) => {
const mergedById = new Map<string, any>()
;[...existingOptions, ...nextOptions].forEach((item: any) => {
if (!item?.id) {
return
}
mergedById.set(item.id, item)
})
return Array.from(mergedById.values())
}
export const loadLinkedTenantSummary = async (organizationId: string): Promise<OrganizationTenantSummary> => {
if (!organizationId) {
return emptyOrganizationTenantSummary
}
const { data } = await axios.get('/tenants', {
params: {
organizations: organizationId,
limit: 5,
page: 0,
sort: 'asc',
field: 'name',
},
})
const normalizedRows = normalizeTenantRows(data?.rows)
return {
rows: normalizedRows,
count: typeof data?.count === 'number' ? data.count : normalizedRows.length,
}
}
export const loadLinkedTenantSummaries = async (
organizationIds: string[],
): Promise<OrganizationTenantSummaryMap> => {
const uniqueOrganizationIds = Array.from(new Set(organizationIds.filter(Boolean)))
if (!uniqueOrganizationIds.length) {
return {}
}
const settledResults = await Promise.allSettled(
uniqueOrganizationIds.map(async (organizationId) => ({
summary: await loadLinkedTenantSummary(organizationId),
})),
)
return settledResults.reduce((acc: OrganizationTenantSummaryMap, result, index) => {
const organizationId = uniqueOrganizationIds[index]
if (result.status === 'fulfilled') {
acc[organizationId] = result.value.summary
return acc
}
console.error(`Failed to load linked tenants for organization ${organizationId}:`, result.reason)
acc[organizationId] = emptyOrganizationTenantSummary
return acc
}, {})
}

View File

@ -1,171 +1,320 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import { mdiChartTimelineVariant, mdiDomain, mdiOfficeBuildingCogOutline } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import { useRouter } from 'next/router'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import FormField from '../../components/FormField'
import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/organizations/organizationsSlice'
import {
emptyOrganizationTenantSummary,
getTenantManageHref,
getTenantSetupHref,
getTenantViewHref,
loadLinkedTenantSummary,
} from '../../helpers/organizationTenants'
import { hasPermission } from '../../helpers/userPermissions'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { fetch, update } from '../../stores/organizations/organizationsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import {hasPermission} from "../../helpers/userPermissions";
const emptyOrganization = {
name: '',
}
const EditOrganizationsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'name': '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { organizations } = useAppSelector((state) => state.organizations)
const { currentUser } = useAppSelector((state) => state.auth);
const { currentUser } = useAppSelector((state) => state.auth)
const { id } = router.query
const [initialValues, setInitialValues] = useState(emptyOrganization)
const [submitMode, setSubmitMode] = useState<'list' | 'tenant'>('list')
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const organizationId = useMemo(
() => (typeof router.query.id === 'string' ? router.query.id : ''),
[router.query.id],
)
const tenantIdFromQuery = useMemo(
() => (typeof router.query.tenantId === 'string' ? router.query.tenantId : ''),
[router.query.tenantId],
)
const tenantNameFromQuery = useMemo(
() => (typeof router.query.tenantName === 'string' ? router.query.tenantName : ''),
[router.query.tenantName],
)
const canReadTenants = hasPermission(currentUser, 'READ_TENANTS')
const canUpdateTenants = hasPermission(currentUser, 'UPDATE_TENANTS')
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
if (!organizationId) return
dispatch(fetch({ id: organizationId }))
}, [dispatch, organizationId])
useEffect(() => {
if (typeof organizations === 'object') {
setInitialValues(organizations)
if (!organizations || typeof organizations !== 'object' || Array.isArray(organizations)) {
return
}
setInitialValues({
name: organizations.name || '',
})
}, [organizations])
useEffect(() => {
if (typeof organizations === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (organizations)[el])
setInitialValues(newInitialVal);
}
}, [organizations])
if (!organizationId) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
return
}
let isActive = true
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (isActive) {
setLinkedTenantSummary(summary)
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization edit:', error)
if (isActive) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
}
})
return () => {
isActive = false
}
}, [organizationId])
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
const resultAction = await dispatch(update({ id: organizationId, data }))
if (!update.fulfilled.match(resultAction)) {
return
}
if (submitMode === 'tenant' && organizationId) {
await router.push(getTenantSetupHref(organizationId, data.name || initialValues.name || ''))
return
}
await router.push('/organizations/organizations-list')
}
return (
<>
<Head>
<title>{getPageTitle('Edit organizations')}</title>
<title>{getPageTitle('Edit organization')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit organizations'} main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Edit organization" main>
{''}
</SectionTitleLineWithButton>
<NotificationBar
color="info"
icon={mdiOfficeBuildingCogOutline}
button={
<BaseButton
href={getTenantSetupHref(organizationId, initialValues.name || organizations?.name || '')}
color="info"
label="Open tenant setup"
small
/>
}
>
Organizations stay lightweight here. Tenant settings like slug, domain, timezone, currency, and activation
still live under <strong>Tenants</strong>, so you can jump straight into tenant setup after saving changes.
</NotificationBar>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ values }) => {
const organizationNameForLinks = values.name || initialValues.name || organizations?.name || ''
return (
<Form>
<ConnectedEntityNotice
contextLabel={tenantIdFromQuery ? tenantNameFromQuery || 'Tenant flow' : undefined}
contextHref={tenantIdFromQuery ? getTenantViewHref(tenantIdFromQuery, organizationId, organizationNameForLinks) : undefined}
contextActionLabel="View tenant"
description={
<>
Organizations stay lightweight here, while tenants hold shared settings like slug, domain,
timezone, currency, and activation.
{linkedTenantSummary.count
? ` ${linkedTenantSummary.count} tenant${linkedTenantSummary.count === 1 ? '' : 's'} currently connected.`
: ' No tenant is connected yet.'}
</>
}
actions={[
{
href: getTenantSetupHref(organizationId, organizationNameForLinks),
label: linkedTenantSummary.count ? 'Add tenant link' : 'Create tenant link',
color: 'info',
},
...(tenantIdFromQuery
? canUpdateTenants
? [
{
href: getTenantManageHref(tenantIdFromQuery, organizationId, organizationNameForLinks),
label: 'Back to tenant',
color: 'white',
outline: true,
},
]
: canReadTenants
? [
{
href: getTenantViewHref(tenantIdFromQuery, organizationId, organizationNameForLinks),
label: 'View tenant',
color: 'white',
outline: true,
},
]
: []
: []),
]}
/>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<div className="mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-semibold text-gray-700 dark:text-gray-100">Linked tenants</p>
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
{linkedTenantSummary.count
? `${linkedTenantSummary.count} tenant${linkedTenantSummary.count === 1 ? '' : 's'} currently linked to this organization.`
: 'This organization is not linked to a tenant yet. Create or connect one from here to keep the workspace fully wired.'}
</p>
</div>
<BaseButton
href={getTenantSetupHref(organizationId, organizationNameForLinks)}
color="info"
label={linkedTenantSummary.count ? 'Create another tenant link' : 'Create tenant & link'}
className="w-full sm:w-auto"
/>
</div>
{linkedTenants.length ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{linkedTenants.map((tenant: any) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel="Tenant"
title={tenant.name || 'Unnamed tenant'}
details={[
{ label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency },
{ label: 'Status', value: tenant.is_active ? 'Active' : 'Inactive' },
]}
actions={[
...(canReadTenants
? [
{
href: getTenantViewHref(tenant.id, organizationId, organizationNameForLinks),
label: 'View tenant',
color: 'info',
outline: true,
},
]
: []),
...(canUpdateTenants
? [
{
href: getTenantManageHref(tenant.id, organizationId, organizationNameForLinks),
label: 'Manage link',
color: 'info',
},
]
: []),
]}
helperText={canUpdateTenants ? 'Context preserved when managing this tenant link.' : undefined}
/>
))}
</div>
) : (
<div className="mt-4 rounded-xl border border-gray-200 bg-white px-4 py-4 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300">
Create a tenant from here to preselect this organization automatically, then save the tenant to establish the link.
</div>
)}
</div>
<FormField
label="Organization name"
labelFor="name"
help="Update the workspace name here, then continue to tenant setup if you need to attach or configure tenant-level settings."
>
<Field name="name" id="name" placeholder="Acme Operations" />
</FormField>
<BaseDivider />
<div className="mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-semibold text-gray-700 dark:text-gray-100">Need tenant settings too?</p>
<p className="mt-1 leading-6">
Save this organization, then continue into tenant setup to link it and manage shared settings like
slug, domain, timezone, currency, and activation.
</p>
</div>
<BaseButton
href={getTenantSetupHref(organizationId, organizationNameForLinks)}
color="white"
outline
icon={mdiDomain}
label="Review tenant form"
className="w-full sm:w-auto"
/>
</div>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/organizations/organizations-list')}/>
</BaseButtons>
</Form>
<BaseButtons
type="justify-start sm:justify-end"
className="items-stretch sm:items-center"
classAddon="w-full sm:w-auto mr-0 sm:mr-3 mb-3"
>
<BaseButton
type="submit"
color="success"
label="Save & set up tenant"
onClick={() => setSubmitMode('tenant')}
/>
<BaseButton
type="submit"
color="info"
label="Save changes"
onClick={() => setSubmitMode('list')}
/>
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton
type="reset"
color="danger"
outline
label="Cancel"
onClick={() => router.push('/organizations/organizations-list')}
/>
</BaseButtons>
</Form>
)
}}
</Formik>
</CardBox>
</SectionMain>
@ -174,15 +323,7 @@ const EditOrganizationsPage = () => {
}
EditOrganizationsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_ORGANIZATIONS'}
>
{page}
</LayoutAuthenticated>
)
return <LayoutAuthenticated permission={'UPDATE_ORGANIZATIONS'}>{page}</LayoutAuthenticated>
}
export default EditOrganizationsPage

View File

@ -1,124 +1,121 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import { mdiChartTimelineVariant, mdiDomain, mdiOfficeBuildingCogOutline } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import React, { ReactElement, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import { useRouter } from 'next/router'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import FormField from '../../components/FormField'
import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { getTenantSetupHref } from '../../helpers/organizationTenants'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { create } from '../../stores/organizations/organizationsSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
name: '',
name: '',
}
const OrganizationsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const [submitMode, setSubmitMode] = useState<'list' | 'tenant'>('list')
const handleSubmit = async (data) => {
await dispatch(create(data))
const resultAction = await dispatch(create(data))
if (!create.fulfilled.match(resultAction)) {
return
}
const createdOrganization = resultAction.payload
if (submitMode === 'tenant' && createdOrganization?.id) {
await router.push(getTenantSetupHref(createdOrganization.id, createdOrganization.name || data.name || ''))
return
}
await router.push('/organizations/organizations-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('New organization')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New organization" main>
{''}
</SectionTitleLineWithButton>
<NotificationBar
color="info"
icon={mdiOfficeBuildingCogOutline}
button={<BaseButton href={getTenantSetupHref()} color="info" label="Open tenant setup" small />}
>
Organizations in this app are lightweight workspace records. Tenant settings like slug, domain, timezone,
currency, and activation live under <strong>Tenants</strong>.
</NotificationBar>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
<Form>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<FormField
label="Organization name"
labelFor="name"
help="Create the organization here, then link it to a tenant if this workspace should inherit tenant-level settings."
>
<Field name="name" id="name" placeholder="Acme Operations" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<div className="mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-semibold text-gray-700 dark:text-gray-100">What happens next?</p>
<p className="mt-1 leading-6">
After saving, you can go straight into tenant setup to attach this organization and fill in the
tenants business settings.
</p>
</div>
<BaseButton
href={getTenantSetupHref()}
color="white"
outline
icon={mdiDomain}
label="Review tenant form"
className="w-full sm:w-auto"
/>
</div>
</div>
<BaseButtons
type="justify-start sm:justify-end"
className="items-stretch sm:items-center"
classAddon="w-full sm:w-auto mr-0 sm:mr-3 mb-3"
>
<BaseButton
type="submit"
color="success"
label="Save & set up tenant"
onClick={() => setSubmitMode('tenant')}
/>
<BaseButton type="submit" color="info" label="Save organization" onClick={() => setSubmitMode('list')} />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/organizations/organizations-list')}/>
<BaseButton
type="reset"
color="danger"
outline
label="Cancel"
onClick={() => router.push('/organizations/organizations-list')}
/>
</BaseButtons>
</Form>
</Formik>
@ -129,15 +126,7 @@ const OrganizationsNew = () => {
}
OrganizationsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_ORGANIZATIONS'}
>
{page}
</LayoutAuthenticated>
)
return <LayoutAuthenticated permission={'CREATE_ORGANIZATIONS'}>{page}</LayoutAuthenticated>
}
export default OrganizationsNew

View File

@ -1,4 +1,4 @@
import React, { ReactElement, useEffect } from 'react';
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import {hasPermission} from "../../helpers/userPermissions";
import { emptyOrganizationTenantSummary, getOrganizationManageHref, getTenantManageHref, getTenantSetupHref, getTenantViewHref, loadLinkedTenantSummary } from '../../helpers/organizationTenants'
const OrganizationsView = () => {
@ -29,19 +32,45 @@ const OrganizationsView = () => {
const { organizations } = useAppSelector((state) => state.organizations)
const { currentUser } = useAppSelector((state) => state.auth);
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
}
const tenantIdFromQuery = typeof router.query.tenantId === 'string' ? router.query.tenantId : '';
const tenantNameFromQuery = typeof router.query.tenantName === 'string' ? router.query.tenantName : '';
const canReadTenants = hasPermission(currentUser, 'READ_TENANTS');
const canUpdateTenants = hasPermission(currentUser, 'UPDATE_TENANTS');
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [];
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
useEffect(() => {
if (!id || typeof id !== 'string') {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
return
}
let isActive = true
loadLinkedTenantSummary(id)
.then((summary) => {
if (isActive) {
setLinkedTenantSummary(summary)
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization view:', error)
if (isActive) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
}
})
return () => {
isActive = false
}
}, [id]);
return (
<>
@ -49,14 +78,27 @@ const OrganizationsView = () => {
<title>{getPageTitle('View organizations')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View organizations')} main>
<BaseButton
color='info'
label='Edit'
href={`/organizations/organizations-edit/?id=${id}`}
/>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'View organization'} main>
<div className='flex flex-col gap-1 sm:items-end'>
<BaseButton
color='info'
label='Edit'
href={getOrganizationManageHref(typeof id === 'string' ? id : '', tenantIdFromQuery, tenantNameFromQuery)}
/>
{tenantIdFromQuery ? (
<p className='text-xs font-medium text-blue-700 dark:text-blue-200'>
Context preserved from the tenant flow.
</p>
) : null}
</div>
</SectionTitleLineWithButton>
<CardBox>
<ConnectedEntityNotice
compact
contextLabel={tenantIdFromQuery ? tenantNameFromQuery || 'Tenant flow' : undefined}
contextHref={tenantIdFromQuery ? getTenantViewHref(tenantIdFromQuery, typeof id === 'string' ? id : '', organizations?.name || '') : undefined}
contextActionLabel="View tenant"
/>
@ -64,6 +106,68 @@ const OrganizationsView = () => {
<p className={'block font-bold mb-2'}>Name</p>
<p>{organizations?.name}</p>
</div>
<div className='mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<p className='block font-bold mb-1'>Linked tenants</p>
<p className='text-sm leading-6 text-gray-600 dark:text-gray-300'>
{linkedTenantSummary.count
? `${linkedTenantSummary.count} tenant${linkedTenantSummary.count === 1 ? '' : 's'} currently linked to this organization.`
: 'This organization is not linked to a tenant yet.'}
</p>
</div>
<BaseButton
href={getTenantSetupHref(typeof id === 'string' ? id : '', organizations?.name)}
color='info'
label={linkedTenantSummary.count ? 'Create another tenant link' : 'Create tenant & link'}
className='w-full sm:w-auto'
/>
</div>
{linkedTenants.length ? (
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
{linkedTenants.map((tenant: any) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel='Tenant'
title={tenant.name || 'Unnamed tenant'}
details={[
{ label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone },
]}
actions={[
...(canReadTenants
? [
{
href: getTenantViewHref(tenant.id, typeof id === 'string' ? id : '', organizations?.name || ''),
label: 'View tenant',
color: 'info',
outline: true,
},
]
: []),
...(canUpdateTenants
? [
{
href: getTenantManageHref(tenant.id, typeof id === 'string' ? id : '', organizations?.name || ''),
label: 'Manage link',
color: 'info',
},
]
: []),
]}
helperText={canUpdateTenants ? 'Context preserved when managing this tenant link.' : undefined}
/>
))}
</div>
) : (
<div className='mt-4 rounded-xl border border-gray-200 bg-white px-4 py-4 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300'>
Create a tenant from here to preselect this organization automatically, then save the tenant to establish the link.
</div>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ const TenantsTablesPage = () => {
{label: 'Organizations', title: 'organizations'},{label: 'Properties', title: 'properties'},{label: 'Auditlogs', title: 'audit_logs'},
{label: 'Linked organizations', title: 'organizations'},{label: 'Properties', title: 'properties'},{label: 'Auditlogs', title: 'audit_logs'},
]);

View File

@ -1,585 +1,259 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import { mdiChartTimelineVariant, mdiOfficeBuildingCogOutline } from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import { useRouter } from 'next/router'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import FormField from '../../components/FormField'
import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectFieldMany } from '../../components/SelectFieldMany'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/tenants/tenantsSlice'
import { getPageTitle } from '../../config'
import { getOrganizationManageHref } from '../../helpers/organizationTenants'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
import { create } from '../../stores/tenants/tenantsSlice'
const initialValues = {
name: '',
slug: '',
legal_name: '',
primary_domain: '',
timezone: '',
default_currency: '',
is_active: false,
organizations: [],
properties: [],
audit_logs: [],
const defaultInitialValues = {
name: '',
slug: '',
legal_name: '',
primary_domain: '',
timezone: '',
default_currency: '',
is_active: false,
organizations: [],
properties: [],
audit_logs: [],
}
const TenantsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const [initialValues, setInitialValues] = useState(defaultInitialValues)
const [linkedOrganization, setLinkedOrganization] = useState<{ id: string; name: string } | null>(null)
const [prefillError, setPrefillError] = useState('')
const organizationId = useMemo(
() => (typeof router.query.organizationId === 'string' ? router.query.organizationId : ''),
[router.query.organizationId],
)
const organizationNameFromQuery = useMemo(
() => (typeof router.query.organizationName === 'string' ? router.query.organizationName : ''),
[router.query.organizationName],
)
useEffect(() => {
if (!router.isReady) {
return
}
if (!organizationId) {
setLinkedOrganization(null)
setPrefillError('')
setInitialValues(defaultInitialValues)
return
}
let isActive = true
const loadOrganization = async () => {
try {
setPrefillError('')
const { data } = await axios.get(`/organizations/${organizationId}`)
if (!isActive) {
return
}
const organizationOption = data?.id ? [{ id: data.id, name: data.name }] : []
const preferredName = data?.name || organizationNameFromQuery
setLinkedOrganization(data?.id ? { id: data.id, name: data.name } : null)
setInitialValues({
...defaultInitialValues,
name: preferredName,
organizations: organizationOption,
})
} catch (error) {
console.error('Failed to prefill tenant setup from organization:', error)
if (!isActive) {
return
}
setLinkedOrganization(
organizationId
? {
id: organizationId,
name: organizationNameFromQuery || 'Selected organization',
}
: null,
)
setPrefillError('We could not load the full organization record, but you can still choose it manually below.')
setInitialValues({
...defaultInitialValues,
name: organizationNameFromQuery,
})
}
}
loadOrganization()
return () => {
isActive = false
}
}, [organizationId, organizationNameFromQuery, router.isReady])
const handleSubmit = async (data) => {
await dispatch(create(data))
const resultAction = await dispatch(create(data))
if (!create.fulfilled.match(resultAction)) {
return
}
await router.push('/tenants/tenants-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('New tenant')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New tenant" main>
{''}
</SectionTitleLineWithButton>
<NotificationBar
color="info"
icon={mdiOfficeBuildingCogOutline}
button={<BaseButton href="/organizations/organizations-new" color="info" label="New organization" small />}
>
Tenants hold shared business settings like slug, domain, timezone, default currency, and activation status.
Link one or more organizations to place them under this tenant.
</NotificationBar>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
{linkedOrganization && (
<div className="mb-6 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
<p className="font-semibold">Starting from organization: {linkedOrganization.name}</p>
<p className="mt-1 leading-6">
This organization will be preselected below so you can complete tenant setup in one flow.
</p>
<div className="mt-3">
<BaseButton
href={getOrganizationManageHref(linkedOrganization.id)}
color="info"
outline
small
label="Back to organization"
/>
</div>
</div>
)}
{prefillError ? (
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
{prefillError}
</div>
) : null}
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
<Form>
<FormField
label="Tenantname"
>
<Field
name="name"
placeholder="Tenantname"
/>
</FormField>
<FormField
label="Tenantslug"
>
<Field
name="slug"
placeholder="Tenantslug"
/>
</FormField>
<FormField
label="Legalname"
>
<Field
name="legal_name"
placeholder="Legalname"
/>
</FormField>
<FormField
label="Primarydomain"
>
<Field
name="primary_domain"
placeholder="Primarydomain"
/>
</FormField>
<FormField
label="Timezone"
>
<Field
name="timezone"
placeholder="Timezone"
/>
</FormField>
<FormField
label="Defaultcurrency"
>
<Field
name="default_currency"
placeholder="Defaultcurrency"
/>
</FormField>
<FormField label='Isactive' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
></Field>
</FormField>
<FormField label='Organizations' labelFor='organizations'>
<Field
name='organizations'
id='organizations'
itemRef={'organizations'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='Properties' labelFor='properties'>
<Field
name='properties'
id='properties'
itemRef={'properties'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='Auditlogs' labelFor='audit_logs'>
<Field
name='audit_logs'
id='audit_logs'
itemRef={'audit_logs'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField
label="Tenant name"
labelFor="name"
help="Use the customer-facing or workspace name for this tenant."
>
<Field name="name" id="name" placeholder="Acme Hospitality" />
</FormField>
<FormField
label="Tenant slug"
labelFor="slug"
help="Short internal identifier, often URL-friendly. Example: acme-hospitality"
>
<Field name="slug" id="slug" placeholder="acme-hospitality" />
</FormField>
<FormField label="Legal name" labelFor="legal_name" help="Optional registered business name.">
<Field name="legal_name" id="legal_name" placeholder="Acme Hospitality LLC" />
</FormField>
<FormField
label="Primary domain"
labelFor="primary_domain"
help="Optional domain used by this tenant, such as app.acme.com"
>
<Field name="primary_domain" id="primary_domain" placeholder="app.acme.com" />
</FormField>
<FormField label="Timezone" labelFor="timezone" help="Example: America/New_York">
<Field name="timezone" id="timezone" placeholder="America/New_York" />
</FormField>
<FormField label="Default currency" labelFor="default_currency" help="Example: USD">
<Field name="default_currency" id="default_currency" placeholder="USD" />
</FormField>
<FormField label="Active tenant" labelFor="is_active" help="Turn this on when the tenant is ready to use.">
<Field name="is_active" id="is_active" component={SwitchField}></Field>
</FormField>
<FormField
label="Organizations"
labelFor="organizations"
help={
linkedOrganization
? `${linkedOrganization.name} is preselected. You can keep it, remove it, or add more organizations.`
: 'Link the organizations that belong to this tenant.'
}
>
<Field
name="organizations"
id="organizations"
itemRef={'organizations'}
options={initialValues.organizations}
component={SelectFieldMany}
></Field>
</FormField>
<FormField label="Properties" labelFor="properties" help="Optional: attach properties managed by this tenant.">
<Field
name="properties"
id="properties"
itemRef={'properties'}
options={[]}
component={SelectFieldMany}
></Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButtons
type="justify-start sm:justify-end"
className="items-stretch sm:items-center"
classAddon="w-full sm:w-auto mr-0 sm:mr-3 mb-3"
>
<BaseButton type="submit" color="info" label="Create tenant" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/tenants/tenants-list')}/>
<BaseButton
type="reset"
color="danger"
outline
label="Cancel"
onClick={() => router.push('/tenants/tenants-list')}
/>
</BaseButtons>
</Form>
</Formik>
@ -590,15 +264,7 @@ const TenantsNew = () => {
}
TenantsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_TENANTS'}
>
{page}
</LayoutAuthenticated>
)
return <LayoutAuthenticated permission={'CREATE_TENANTS'}>{page}</LayoutAuthenticated>
}
export default TenantsNew

View File

@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import {hasPermission} from "../../helpers/userPermissions";
import { getOrganizationManageHref, getOrganizationViewHref, getTenantManageHref } from '../../helpers/organizationTenants'
const TenantsView = () => {
@ -32,9 +35,13 @@ const TenantsView = () => {
const { id } = router.query;
const organizationIdFromQuery = typeof router.query.organizationId === 'string' ? router.query.organizationId : '';
const organizationNameFromQuery = typeof router.query.organizationName === 'string' ? router.query.organizationName : '';
const linkedOrganizations = Array.isArray(tenants?.organizations) ? tenants.organizations : [];
const canReadOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS');
const canUpdateOrganizations = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS');
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
}
@ -50,13 +57,26 @@ const TenantsView = () => {
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View tenants')} main>
<BaseButton
color='info'
label='Edit'
href={`/tenants/tenants-edit/?id=${id}`}
/>
<div className='flex flex-col gap-1 sm:items-end'>
<BaseButton
color='info'
label='Edit'
href={getTenantManageHref(typeof id === 'string' ? id : '', organizationIdFromQuery, organizationNameFromQuery)}
/>
{organizationIdFromQuery ? (
<p className='text-xs font-medium text-blue-700 dark:text-blue-200'>
Context preserved from the organization flow.
</p>
) : null}
</div>
</SectionTitleLineWithButton>
<CardBox>
<ConnectedEntityNotice
compact
contextLabel={organizationIdFromQuery ? organizationNameFromQuery || 'Organization flow' : undefined}
contextHref={organizationIdFromQuery ? getOrganizationViewHref(organizationIdFromQuery, typeof id === 'string' ? id : '', tenants?.name || '') : undefined}
contextActionLabel="View organization"
/>
@ -275,164 +295,64 @@ const TenantsView = () => {
disabled
/>
</FormField>
<>
<p className={'block font-bold mb-2'}>Organizations</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{tenants.organizations && Array.isArray(tenants.organizations) &&
tenants.organizations.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/organizations/organizations-view/?id=${item.id}`)}>
<td data-label="name">
{ item.name }
</td>
</tr>
))}
</tbody>
</table>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className={'block font-bold mb-1'}>Linked organizations</p>
<p className='text-sm text-gray-500 dark:text-dark-600'>
{linkedOrganizations.length
? `${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} currently attached to this tenant.`
: 'No organizations are linked to this tenant yet.'}
</p>
</div>
{!tenants?.organizations?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
<BaseButton
color='info'
outline
small
label='Manage links'
href={getTenantManageHref(typeof id === 'string' ? id : '', organizationIdFromQuery, organizationNameFromQuery)}
/>
</div>
{linkedOrganizations.length ? (
<div className='mb-6 grid gap-3 sm:grid-cols-2'>
{linkedOrganizations.map((item: any) => (
<ConnectedEntityCard
key={item.id}
entityLabel='Organization'
title={item.name || 'Unnamed organization'}
className='bg-gray-50 dark:bg-dark-800'
actions={[
...(canReadOrganizations
? [
{
href: getOrganizationViewHref(item.id, typeof id === 'string' ? id : '', tenants?.name || ''),
label: 'View organization',
color: 'info',
outline: true,
},
]
: []),
...(canUpdateOrganizations
? [
{
href: getOrganizationManageHref(item.id, typeof id === 'string' ? id : '', tenants?.name || ''),
label: 'Edit organization',
color: 'info',
},
]
: []),
]}
helperText={canUpdateOrganizations ? 'Context preserved when editing this organization.' : undefined}
/>
))}
</div>
) : (
<div className='mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-4 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300'>
Add organizations from the tenant edit form when this tenant should own one or more workspaces.
</div>
)}
</>
<>
<p className={'block font-bold mb-2'}>Properties</p>
<CardBox