Autosave: 20260404-032450
This commit is contained in:
parent
9ca9df3974
commit
b4ba3c2646
104
frontend/src/components/ConnectedEntityCard.tsx
Normal file
104
frontend/src/components/ConnectedEntityCard.tsx
Normal 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
|
||||
106
frontend/src/components/ConnectedEntityNotice.tsx
Normal file
106
frontend/src/components/ConnectedEntityNotice.tsx
Normal 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
|
||||
@ -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 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 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) => (
|
||||
<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'>
|
||||
<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'>
|
||||
|
||||
|
||||
<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>
|
||||
<div className='font-medium line-clamp-4'>{item.name}</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<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
|
||||
|
||||
@ -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
|
||||
@ -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,7 +172,33 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
||||
loadData(page);
|
||||
setCurrentPage(page);
|
||||
};
|
||||
useEffect(() => {
|
||||
const organizationRows = Array.isArray(organizations) ? organizations : [];
|
||||
|
||||
if (!organizationRows.length) {
|
||||
setLinkedTenantSummaries({});
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -179,8 +207,9 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
||||
handleDeleteModalAction,
|
||||
`organizations`,
|
||||
currentUser,
|
||||
linkedTenantSummaries,
|
||||
).then((newCols) => setColumns(newCols));
|
||||
}, [currentUser]);
|
||||
}, [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`}
|
||||
|
||||
@ -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
|
||||
|
||||
_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,
|
||||
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) => {
|
||||
|
||||
return [
|
||||
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>,
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@ -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`}>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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'}/>
|
||||
),
|
||||
|
||||
@ -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) => {
|
||||
|
||||
199
frontend/src/helpers/organizationTenants.ts
Normal file
199
frontend/src/helpers/organizationTenants.ts
Normal 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
|
||||
}, {})
|
||||
}
|
||||
@ -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 [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 { id } = router.query
|
||||
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);
|
||||
if (!organizationId) {
|
||||
setLinkedTenantSummary(emptyOrganizationTenantSummary)
|
||||
return
|
||||
}
|
||||
}, [organizations])
|
||||
|
||||
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>
|
||||
<CardBox>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
|
||||
<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)}>
|
||||
{({ 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,
|
||||
},
|
||||
]
|
||||
: []
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<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="Name"
|
||||
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"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<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">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>
|
||||
|
||||
<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')}/>
|
||||
<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
|
||||
|
||||
@ -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: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
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"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<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
|
||||
tenant’s 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
|
||||
|
||||
@ -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>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'View organization'} main>
|
||||
<div className='flex flex-col gap-1 sm:items-end'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Edit'
|
||||
href={`/organizations/organizations-edit/?id=${id}`}
|
||||
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"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@ -65,6 +107,68 @@ const OrganizationsView = () => {
|
||||
<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
@ -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'},
|
||||
|
||||
]);
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
const initialValues = {
|
||||
|
||||
import { create } from '../../stores/tenants/tenantsSlice'
|
||||
|
||||
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>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
<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>
|
||||
{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="Tenant name"
|
||||
labelFor="name"
|
||||
help="Use the customer-facing or workspace name for this tenant."
|
||||
>
|
||||
<Field
|
||||
name="name"
|
||||
placeholder="Tenantname"
|
||||
/>
|
||||
<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"
|
||||
placeholder="Tenantslug"
|
||||
/>
|
||||
<Field name="slug" id="slug" placeholder="acme-hospitality" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Legalname"
|
||||
>
|
||||
<Field
|
||||
name="legal_name"
|
||||
placeholder="Legalname"
|
||||
/>
|
||||
<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"
|
||||
placeholder="Primarydomain"
|
||||
/>
|
||||
<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="Timezone"
|
||||
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="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}
|
||||
name="organizations"
|
||||
id="organizations"
|
||||
itemRef={'organizations'}
|
||||
options={initialValues.organizations}
|
||||
component={SelectFieldMany}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Organizations' labelFor='organizations'>
|
||||
<FormField label="Properties" labelFor="properties" help="Optional: attach properties managed by this tenant.">
|
||||
<Field
|
||||
name='organizations'
|
||||
id='organizations'
|
||||
itemRef={'organizations'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Properties' labelFor='properties'>
|
||||
<Field
|
||||
name='properties'
|
||||
id='properties'
|
||||
name="properties"
|
||||
id="properties"
|
||||
itemRef={'properties'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<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
|
||||
|
||||
@ -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>
|
||||
<div className='flex flex-col gap-1 sm:items-end'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Edit'
|
||||
href={`/tenants/tenants-edit/?id=${id}`}
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user