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 React, { useEffect, useState } from 'react'
|
||||||
import ImageField from '../ImageField';
|
import Link from 'next/link'
|
||||||
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 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 = {
|
type Props = {
|
||||||
organizations: any[];
|
organizations: any[]
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void
|
||||||
currentPage: number;
|
currentPage: number
|
||||||
numPages: number;
|
numPages: number
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void
|
||||||
};
|
}
|
||||||
|
|
||||||
const CardOrganizations = ({
|
const CardOrganizations = ({
|
||||||
organizations,
|
organizations,
|
||||||
@ -28,84 +26,102 @@ const CardOrganizations = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
const bgColor = useAppSelector((state) => state.style.cardsColor)
|
||||||
);
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
const corners = useAppSelector((state) => state.style.corners)
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
const focusRing = useAppSelector((state) => state.style.focusRingColor)
|
||||||
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 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 (
|
return (
|
||||||
<div className={'p-4'}>
|
<div className='p-4'>
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
<ul
|
<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'>
|
||||||
role='list'
|
{!loading &&
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
organizations.map((item) => (
|
||||||
>
|
<li
|
||||||
{!loading && organizations.map((item, index) => (
|
key={item.id}
|
||||||
<li
|
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||||
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
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
<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`}>
|
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='text-lg font-bold leading-6 line-clamp-1'>
|
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='line-clamp-1 text-lg font-bold leading-6'>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className='ml-auto'>
|
||||||
<div className='ml-auto '>
|
<ListActionsPopover
|
||||||
<ListActionsPopover
|
onDelete={onDelete}
|
||||||
onDelete={onDelete}
|
itemId={item.id}
|
||||||
itemId={item.id}
|
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
||||||
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
||||||
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
/>
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
</div>
|
||||||
|
|
||||||
/>
|
|
||||||
</div>
|
</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'>
|
||||||
<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'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
|
<dt className='text-gray-500 dark:text-dark-600'>Name</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>{item.name}</div>
|
||||||
{ item.name }
|
</dd>
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-y-3 py-3'>
|
||||||
|
<dt className='text-gray-500 dark:text-dark-600'>Linked tenants</dt>
|
||||||
</dl>
|
<dd>
|
||||||
</li>
|
<LinkedTenantsPreview
|
||||||
))}
|
summary={linkedTenantSummaries[item.id]}
|
||||||
|
emptyMessage='This organization is not linked to a tenant yet.'
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
{!loading && organizations.length === 0 && (
|
{!loading && organizations.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='my-6 flex items-center justify-center'>
|
||||||
<Pagination
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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,
|
GridColDef,
|
||||||
} from '@mui/x-data-grid';
|
} from '@mui/x-data-grid';
|
||||||
import {loadColumns} from "./configureOrganizationsCols";
|
import {loadColumns} from "./configureOrganizationsCols";
|
||||||
|
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import dataFormatter from '../../helpers/dataFormatter'
|
import dataFormatter from '../../helpers/dataFormatter'
|
||||||
import {dataGridStyles} from "../../styles";
|
import {dataGridStyles} from "../../styles";
|
||||||
@ -33,6 +34,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
const [filterRequest, setFilterRequest] = React.useState('');
|
const [filterRequest, setFilterRequest] = React.useState('');
|
||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||||
const [selectedRows, setSelectedRows] = useState([]);
|
const [selectedRows, setSelectedRows] = useState([]);
|
||||||
|
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
|
||||||
const [sortModel, setSortModel] = useState([
|
const [sortModel, setSortModel] = useState([
|
||||||
{
|
{
|
||||||
field: '',
|
field: '',
|
||||||
@ -170,17 +172,44 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
loadData(page);
|
loadData(page);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const organizationRows = Array.isArray(organizations) ? organizations : [];
|
||||||
|
|
||||||
|
if (!organizationRows.length) {
|
||||||
|
setLinkedTenantSummaries({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
let isActive = true;
|
||||||
if (!currentUser) return;
|
|
||||||
|
|
||||||
loadColumns(
|
loadLinkedTenantSummaries(organizationRows.map((item: any) => item?.id))
|
||||||
handleDeleteModalAction,
|
.then((summaries) => {
|
||||||
`organizations`,
|
if (isActive) {
|
||||||
currentUser,
|
setLinkedTenantSummaries(summaries);
|
||||||
).then((newCols) => setColumns(newCols));
|
}
|
||||||
}, [currentUser]);
|
})
|
||||||
|
.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'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={78}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
|
|||||||
@ -1,83 +1,69 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import BaseIcon from '../BaseIcon';
|
import { GridRowParams } from '@mui/x-data-grid'
|
||||||
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 {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 (
|
export const loadColumns = async (
|
||||||
onDelete: Params,
|
onDelete: Params,
|
||||||
entityName: string,
|
_entityName: string,
|
||||||
|
user,
|
||||||
user
|
linkedTenantSummaries: OrganizationTenantSummaryMap = {},
|
||||||
|
|
||||||
) => {
|
) => {
|
||||||
async function callOptionsApi(entityName: string) {
|
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
|
||||||
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
return [
|
||||||
|
{
|
||||||
try {
|
field: 'name',
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
headerName: 'Name',
|
||||||
return data.data;
|
flex: 1,
|
||||||
} catch (error) {
|
minWidth: 180,
|
||||||
console.log(error);
|
filterable: false,
|
||||||
return [];
|
headerClassName: 'datagrid--header',
|
||||||
}
|
cellClassName: 'datagrid--cell',
|
||||||
}
|
editable: hasUpdatePermission,
|
||||||
|
},
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
|
{
|
||||||
|
field: 'linkedTenants',
|
||||||
return [
|
headerName: 'Linked tenants',
|
||||||
|
flex: 1.2,
|
||||||
{
|
minWidth: 220,
|
||||||
field: 'name',
|
filterable: false,
|
||||||
headerName: 'Name',
|
sortable: false,
|
||||||
flex: 1,
|
editable: false,
|
||||||
minWidth: 120,
|
headerClassName: 'datagrid--header',
|
||||||
filterable: false,
|
cellClassName: 'datagrid--cell',
|
||||||
headerClassName: 'datagrid--header',
|
renderCell: (params: any) => (
|
||||||
cellClassName: 'datagrid--cell',
|
<div className='flex min-h-[52px] items-center py-2'>
|
||||||
|
<LinkedTenantsPreview
|
||||||
|
summary={linkedTenantSummaries[params?.row?.id]}
|
||||||
editable: hasUpdatePermission,
|
compact
|
||||||
|
emptyMessage='Not linked yet'
|
||||||
|
/>
|
||||||
},
|
</div>
|
||||||
|
),
|
||||||
{
|
},
|
||||||
field: 'actions',
|
{
|
||||||
type: 'actions',
|
field: 'actions',
|
||||||
minWidth: 30,
|
type: 'actions',
|
||||||
headerClassName: 'datagrid--header',
|
minWidth: 30,
|
||||||
cellClassName: 'datagrid--cell',
|
headerClassName: 'datagrid--header',
|
||||||
getActions: (params: GridRowParams) => {
|
cellClassName: 'datagrid--cell',
|
||||||
|
getActions: (params: GridRowParams) => [
|
||||||
return [
|
<div key={params?.row?.id}>
|
||||||
<div key={params?.row?.id}>
|
<ListActionsPopover
|
||||||
<ListActionsPopover
|
onDelete={onDelete}
|
||||||
onDelete={onDelete}
|
itemId={params?.row?.id}
|
||||||
itemId={params?.row?.id}
|
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
|
||||||
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
|
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
|
||||||
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
/>
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
</div>,
|
||||||
|
],
|
||||||
/>
|
},
|
||||||
</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'>
|
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</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 '>
|
<div className='ml-auto '>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
@ -161,12 +166,27 @@ const CardTenants = ({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex flex-col gap-y-3 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Organizations</dt>
|
<dt className='text-gray-500 dark:text-dark-600'>Linked organizations</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex flex-wrap items-center gap-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
{Array.isArray(item.organizations) && item.organizations.length ? (
|
||||||
{ dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}
|
<>
|
||||||
</div>
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -211,7 +211,7 @@ const TableSampleTenants = ({ filterItems, setFilterItems, filters, showGrid })
|
|||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={72}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
|
|||||||
@ -149,9 +149,9 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'organizations',
|
field: 'organizations',
|
||||||
headerName: 'Organizations',
|
headerName: 'Linked organizations',
|
||||||
flex: 1,
|
flex: 1.2,
|
||||||
minWidth: 120,
|
minWidth: 220,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
@ -161,6 +161,42 @@ export const loadColumns = async (
|
|||||||
type: 'singleSelect',
|
type: 'singleSelect',
|
||||||
valueFormatter: ({ value }) =>
|
valueFormatter: ({ value }) =>
|
||||||
dataFormatter.organizationsManyListFormatter(value).join(', '),
|
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) => (
|
renderEditCell: (params) => (
|
||||||
<DataGridMultiSelect {...params} entityName={'organizations'}/>
|
<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 isRangeFilter = (filter?: FilterDefinition) => Boolean(filter?.number || filter?.date)
|
||||||
|
|
||||||
const getFilterDisplayLabel = (filter?: FilterDefinition) => {
|
const getHumanizedFieldName = (value?: string) => {
|
||||||
const rawLabel = `${filter?.label ?? filter?.title ?? ""}`.trim()
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
if (!rawLabel) {
|
return value
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupFilterDisplayLabel = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
return 'this field'
|
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) => {
|
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 Head from 'next/head'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
|
||||||
import DatePicker from "react-datepicker";
|
import { Field, Form, Formik } from 'formik'
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import { useRouter } from 'next/router'
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
|
import BaseButton from '../../components/BaseButton'
|
||||||
|
import BaseButtons from '../../components/BaseButtons'
|
||||||
|
import BaseDivider from '../../components/BaseDivider'
|
||||||
import CardBox from '../../components/CardBox'
|
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 SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
import { getPageTitle } from '../../config'
|
import { getPageTitle } from '../../config'
|
||||||
|
import {
|
||||||
import { Field, Form, Formik } from 'formik'
|
emptyOrganizationTenantSummary,
|
||||||
import FormField from '../../components/FormField'
|
getTenantManageHref,
|
||||||
import BaseDivider from '../../components/BaseDivider'
|
getTenantSetupHref,
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
getTenantViewHref,
|
||||||
import BaseButton from '../../components/BaseButton'
|
loadLinkedTenantSummary,
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
} from '../../helpers/organizationTenants'
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
import { hasPermission } from '../../helpers/userPermissions'
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
import { fetch, update } from '../../stores/organizations/organizationsSlice'
|
||||||
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 { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
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 EditOrganizationsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const initVals = {
|
|
||||||
|
|
||||||
|
|
||||||
'name': '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
|
||||||
|
|
||||||
const { organizations } = useAppSelector((state) => state.organizations)
|
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(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
if (!organizationId) return
|
||||||
}, [id])
|
|
||||||
|
dispatch(fetch({ id: organizationId }))
|
||||||
|
}, [dispatch, organizationId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof organizations === 'object') {
|
if (!organizations || typeof organizations !== 'object' || Array.isArray(organizations)) {
|
||||||
setInitialValues(organizations)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInitialValues({
|
||||||
|
name: organizations.name || '',
|
||||||
|
})
|
||||||
}, [organizations])
|
}, [organizations])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof organizations === 'object') {
|
if (!organizationId) {
|
||||||
const newInitialVal = {...initVals};
|
setLinkedTenantSummary(emptyOrganizationTenantSummary)
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (organizations)[el])
|
return
|
||||||
setInitialValues(newInitialVal);
|
}
|
||||||
}
|
|
||||||
}, [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) => {
|
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')
|
await router.push('/organizations/organizations-list')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Edit organizations')}</title>
|
<title>{getPageTitle('Edit organization')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit organizations'} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Edit organization" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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>
|
<CardBox>
|
||||||
<Formik
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
enableReinitialize
|
{({ values }) => {
|
||||||
initialValues={initialValues}
|
const organizationNameForLinks = values.name || initialValues.name || organizations?.name || ''
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
<FormField
|
{linkedTenants.length ? (
|
||||||
label="Name"
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
>
|
{linkedTenants.map((tenant: any) => (
|
||||||
<Field
|
<ConnectedEntityCard
|
||||||
name="name"
|
key={tenant.id}
|
||||||
placeholder="Name"
|
entityLabel="Tenant"
|
||||||
/>
|
title={tenant.name || 'Unnamed tenant'}
|
||||||
</FormField>
|
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>
|
||||||
|
|
||||||
|
<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')}
|
||||||
|
/>
|
||||||
<BaseDivider />
|
</BaseButtons>
|
||||||
<BaseButtons>
|
</Form>
|
||||||
<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>
|
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
@ -174,15 +323,7 @@ const EditOrganizationsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EditOrganizationsPage.getLayout = function getLayout(page: ReactElement) {
|
EditOrganizationsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission={'UPDATE_ORGANIZATIONS'}>{page}</LayoutAuthenticated>
|
||||||
<LayoutAuthenticated
|
|
||||||
|
|
||||||
permission={'UPDATE_ORGANIZATIONS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditOrganizationsPage
|
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 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 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 SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
import { getPageTitle } from '../../config'
|
import { getPageTitle } from '../../config'
|
||||||
|
import { getTenantSetupHref } from '../../helpers/organizationTenants'
|
||||||
import { Field, Form, Formik } from 'formik'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
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 { create } from '../../stores/organizations/organizationsSlice'
|
import { create } from '../../stores/organizations/organizationsSlice'
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
import { useAppDispatch } from '../../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
name: '',
|
||||||
|
|
||||||
name: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const OrganizationsNew = () => {
|
const OrganizationsNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const [submitMode, setSubmitMode] = useState<'list' | 'tenant'>('list')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
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')
|
await router.push('/organizations/organizations-list')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('New organization')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New organization" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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>
|
<CardBox>
|
||||||
<Formik
|
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
||||||
initialValues={
|
|
||||||
|
|
||||||
initialValues
|
|
||||||
|
|
||||||
}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
<Form>
|
||||||
|
<FormField
|
||||||
|
label="Organization name"
|
||||||
|
labelFor="name"
|
||||||
<FormField
|
help="Create the organization here, then link it to a tenant if this workspace should inherit tenant-level settings."
|
||||||
label="Name"
|
>
|
||||||
>
|
<Field name="name" id="name" placeholder="Acme Operations" />
|
||||||
<Field
|
</FormField>
|
||||||
name="name"
|
|
||||||
placeholder="Name"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
<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="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>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
@ -129,15 +126,7 @@ const OrganizationsNew = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
OrganizationsNew.getLayout = function getLayout(page: ReactElement) {
|
OrganizationsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission={'CREATE_ORGANIZATIONS'}>{page}</LayoutAuthenticated>
|
||||||
<LayoutAuthenticated
|
|
||||||
|
|
||||||
permission={'CREATE_ORGANIZATIONS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrganizationsNew
|
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 Head from 'next/head'
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
|
|||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
||||||
import SectionMain from "../../components/SectionMain";
|
import SectionMain from "../../components/SectionMain";
|
||||||
import CardBox from "../../components/CardBox";
|
import CardBox from "../../components/CardBox";
|
||||||
|
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
|
||||||
|
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
|
||||||
import BaseButton from "../../components/BaseButton";
|
import BaseButton from "../../components/BaseButton";
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
import BaseDivider from "../../components/BaseDivider";
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
import {mdiChartTimelineVariant} from "@mdi/js";
|
||||||
@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
|
import { emptyOrganizationTenantSummary, getOrganizationManageHref, getTenantManageHref, getTenantSetupHref, getTenantViewHref, loadLinkedTenantSummary } from '../../helpers/organizationTenants'
|
||||||
|
|
||||||
|
|
||||||
const OrganizationsView = () => {
|
const OrganizationsView = () => {
|
||||||
@ -29,19 +32,45 @@ const OrganizationsView = () => {
|
|||||||
const { organizations } = useAppSelector((state) => state.organizations)
|
const { organizations } = useAppSelector((state) => state.organizations)
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
|
||||||
|
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
const tenantIdFromQuery = typeof router.query.tenantId === 'string' ? router.query.tenantId : '';
|
||||||
function removeLastCharacter(str) {
|
const tenantNameFromQuery = typeof router.query.tenantName === 'string' ? router.query.tenantName : '';
|
||||||
console.log(str,`str`)
|
const canReadTenants = hasPermission(currentUser, 'READ_TENANTS');
|
||||||
return str.slice(0, -1);
|
const canUpdateTenants = hasPermission(currentUser, 'UPDATE_TENANTS');
|
||||||
}
|
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id }));
|
dispatch(fetch({ id }));
|
||||||
}, [dispatch, 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -49,14 +78,27 @@ const OrganizationsView = () => {
|
|||||||
<title>{getPageTitle('View organizations')}</title>
|
<title>{getPageTitle('View organizations')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View organizations')} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'View organization'} main>
|
||||||
<BaseButton
|
<div className='flex flex-col gap-1 sm:items-end'>
|
||||||
color='info'
|
<BaseButton
|
||||||
label='Edit'
|
color='info'
|
||||||
href={`/organizations/organizations-edit/?id=${id}`}
|
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>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<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>
|
<p>{organizations?.name}</p>
|
||||||
</div>
|
</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 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 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 SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
import { getPageTitle } from '../../config'
|
import { SelectFieldMany } from '../../components/SelectFieldMany'
|
||||||
|
|
||||||
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 { SwitchField } from '../../components/SwitchField'
|
||||||
|
import { getPageTitle } from '../../config'
|
||||||
import { SelectField } from '../../components/SelectField'
|
import { getOrganizationManageHref } from '../../helpers/organizationTenants'
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { create } from '../../stores/tenants/tenantsSlice'
|
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
import { useAppDispatch } from '../../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
import { create } from '../../stores/tenants/tenantsSlice'
|
||||||
import moment from 'moment';
|
|
||||||
|
const defaultInitialValues = {
|
||||||
const initialValues = {
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
legal_name: '',
|
||||||
name: '',
|
primary_domain: '',
|
||||||
|
timezone: '',
|
||||||
|
default_currency: '',
|
||||||
|
is_active: false,
|
||||||
|
organizations: [],
|
||||||
|
properties: [],
|
||||||
|
audit_logs: [],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
slug: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
legal_name: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
primary_domain: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
timezone: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
default_currency: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_active: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
organizations: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
properties: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
audit_logs: [],
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const TenantsNew = () => {
|
const TenantsNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
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) => {
|
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')
|
await router.push('/tenants/tenants-list')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('New tenant')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New tenant" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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>
|
<CardBox>
|
||||||
<Formik
|
{linkedOrganization && (
|
||||||
initialValues={
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
initialValues
|
{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)}>
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
<Form>
|
||||||
|
<FormField
|
||||||
|
label="Tenant name"
|
||||||
|
labelFor="name"
|
||||||
<FormField
|
help="Use the customer-facing or workspace name for this tenant."
|
||||||
label="Tenantname"
|
>
|
||||||
>
|
<Field name="name" id="name" placeholder="Acme Hospitality" />
|
||||||
<Field
|
</FormField>
|
||||||
name="name"
|
|
||||||
placeholder="Tenantname"
|
<FormField
|
||||||
/>
|
label="Tenant slug"
|
||||||
</FormField>
|
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="Tenantslug"
|
|
||||||
>
|
<FormField
|
||||||
<Field
|
label="Organizations"
|
||||||
name="slug"
|
labelFor="organizations"
|
||||||
placeholder="Tenantslug"
|
help={
|
||||||
/>
|
linkedOrganization
|
||||||
</FormField>
|
? `${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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
<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="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>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
@ -590,15 +264,7 @@ const TenantsNew = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TenantsNew.getLayout = function getLayout(page: ReactElement) {
|
TenantsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <LayoutAuthenticated permission={'CREATE_TENANTS'}>{page}</LayoutAuthenticated>
|
||||||
<LayoutAuthenticated
|
|
||||||
|
|
||||||
permission={'CREATE_TENANTS'}
|
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TenantsNew
|
export default TenantsNew
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
|
|||||||
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
|
||||||
import SectionMain from "../../components/SectionMain";
|
import SectionMain from "../../components/SectionMain";
|
||||||
import CardBox from "../../components/CardBox";
|
import CardBox from "../../components/CardBox";
|
||||||
|
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
|
||||||
|
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
|
||||||
import BaseButton from "../../components/BaseButton";
|
import BaseButton from "../../components/BaseButton";
|
||||||
import BaseDivider from "../../components/BaseDivider";
|
import BaseDivider from "../../components/BaseDivider";
|
||||||
import {mdiChartTimelineVariant} from "@mdi/js";
|
import {mdiChartTimelineVariant} from "@mdi/js";
|
||||||
@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
|
import { getOrganizationManageHref, getOrganizationViewHref, getTenantManageHref } from '../../helpers/organizationTenants'
|
||||||
|
|
||||||
|
|
||||||
const TenantsView = () => {
|
const TenantsView = () => {
|
||||||
@ -32,9 +35,13 @@ const TenantsView = () => {
|
|||||||
|
|
||||||
|
|
||||||
const { id } = router.query;
|
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) {
|
function removeLastCharacter(str) {
|
||||||
console.log(str,`str`)
|
|
||||||
return str.slice(0, -1);
|
return str.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,13 +57,26 @@ const TenantsView = () => {
|
|||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View tenants')} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View tenants')} main>
|
||||||
<BaseButton
|
<div className='flex flex-col gap-1 sm:items-end'>
|
||||||
color='info'
|
<BaseButton
|
||||||
label='Edit'
|
color='info'
|
||||||
href={`/tenants/tenants-edit/?id=${id}`}
|
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>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<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
|
disabled
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<p className={'block font-bold mb-2'}>Organizations</p>
|
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<CardBox
|
<div>
|
||||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
<p className={'block font-bold mb-1'}>Linked organizations</p>
|
||||||
hasTable
|
<p className='text-sm text-gray-500 dark:text-dark-600'>
|
||||||
>
|
{linkedOrganizations.length
|
||||||
<div className='overflow-x-auto'>
|
? `${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} currently attached to this tenant.`
|
||||||
<table>
|
: 'No organizations are linked to this tenant yet.'}
|
||||||
<thead>
|
</p>
|
||||||
<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>
|
</div>
|
||||||
{!tenants?.organizations?.length && <div className={'text-center py-4'}>No data</div>}
|
<BaseButton
|
||||||
</CardBox>
|
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>
|
<p className={'block font-bold mb-2'}>Properties</p>
|
||||||
<CardBox
|
<CardBox
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user