- {item.name}
+ }`}
+ >
+
+
+ ))}
{!loading && organizations.length === 0 && (
-
-
No data to display
+
)}
-
- );
-};
+ )
+}
-export default CardOrganizations;
+export default CardOrganizations
diff --git a/frontend/src/components/Organizations/LinkedTenantsPreview.tsx b/frontend/src/components/Organizations/LinkedTenantsPreview.tsx
new file mode 100644
index 0000000..82c7c79
--- /dev/null
+++ b/frontend/src/components/Organizations/LinkedTenantsPreview.tsx
@@ -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
{emptyMessage}
+ }
+
+ const preview = compact ? tenants.slice(0, 2) : tenants.slice(0, 3)
+ const remainingCount = Math.max(summary.count - preview.length, 0)
+
+ return (
+
+
+ {summary.count} linked
+
+ {preview.map((tenant: any) => (
+
+ {tenant.name || 'Unnamed tenant'}
+
+ ))}
+ {remainingCount > 0 ? (
+
+ +{remainingCount} more
+
+ ) : null}
+
+ )
+}
+
+export default LinkedTenantsPreview
diff --git a/frontend/src/components/Organizations/TableOrganizations.tsx b/frontend/src/components/Organizations/TableOrganizations.tsx
index 4921087..0b03f34 100644
--- a/frontend/src/components/Organizations/TableOrganizations.tsx
+++ b/frontend/src/components/Organizations/TableOrganizations.tsx
@@ -13,6 +13,7 @@ import {
GridColDef,
} from '@mui/x-data-grid';
import {loadColumns} from "./configureOrganizationsCols";
+import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles";
@@ -33,6 +34,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState
([]);
const [selectedRows, setSelectedRows] = useState([]);
+ const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
const [sortModel, setSortModel] = useState([
{
field: '',
@@ -170,17 +172,44 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
loadData(page);
setCurrentPage(page);
};
+ useEffect(() => {
+ const organizationRows = Array.isArray(organizations) ? organizations : [];
-
- useEffect(() => {
- if (!currentUser) return;
+ if (!organizationRows.length) {
+ setLinkedTenantSummaries({});
+ return;
+ }
- loadColumns(
- handleDeleteModalAction,
- `organizations`,
- currentUser,
- ).then((newCols) => setColumns(newCols));
- }, [currentUser]);
+ let isActive = true;
+
+ loadLinkedTenantSummaries(organizationRows.map((item: any) => item?.id))
+ .then((summaries) => {
+ if (isActive) {
+ setLinkedTenantSummaries(summaries);
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load linked tenants for organization table:', error);
+ if (isActive) {
+ setLinkedTenantSummaries({});
+ }
+ });
+
+ return () => {
+ isActive = false;
+ };
+ }, [organizations]);
+
+ useEffect(() => {
+ if (!currentUser) return;
+
+ loadColumns(
+ handleDeleteModalAction,
+ `organizations`,
+ currentUser,
+ linkedTenantSummaries,
+ ).then((newCols) => setColumns(newCols));
+ }, [currentUser, linkedTenantSummaries]);
@@ -211,7 +240,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
`datagrid--row`}
diff --git a/frontend/src/components/Organizations/configureOrganizationsCols.tsx b/frontend/src/components/Organizations/configureOrganizationsCols.tsx
index f0e7dd6..9cad756 100644
--- a/frontend/src/components/Organizations/configureOrganizationsCols.tsx
+++ b/frontend/src/components/Organizations/configureOrganizationsCols.tsx
@@ -1,83 +1,69 @@
-import React from 'react';
-import BaseIcon from '../BaseIcon';
-import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
-import axios from 'axios';
-import {
- GridActionsCellItem,
- GridRowParams,
- GridValueGetterParams,
-} from '@mui/x-data-grid';
-import ImageField from '../ImageField';
-import {saveFile} from "../../helpers/fileSaver";
-import dataFormatter from '../../helpers/dataFormatter'
-import DataGridMultiSelect from "../DataGridMultiSelect";
-import ListActionsPopover from '../ListActionsPopover';
+import React from 'react'
+import { GridRowParams } from '@mui/x-data-grid'
-import {hasPermission} from "../../helpers/userPermissions";
+import LinkedTenantsPreview from './LinkedTenantsPreview'
+import ListActionsPopover from '../ListActionsPopover'
+import { hasPermission } from '../../helpers/userPermissions'
+import { OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
-type Params = (id: string) => void;
+type Params = (id: string) => void
export const loadColumns = async (
- onDelete: Params,
- entityName: string,
-
- user
-
+ onDelete: Params,
+ _entityName: string,
+ user,
+ linkedTenantSummaries: OrganizationTenantSummaryMap = {},
) => {
- async function callOptionsApi(entityName: string) {
-
- if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
-
- try {
- const data = await axios(`/${entityName}/autocomplete?limit=100`);
- return data.data;
- } catch (error) {
- console.log(error);
- return [];
- }
- }
-
- const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
-
- return [
-
- {
- field: 'name',
- headerName: 'Name',
- flex: 1,
- minWidth: 120,
- filterable: false,
- headerClassName: 'datagrid--header',
- cellClassName: 'datagrid--cell',
-
-
- editable: hasUpdatePermission,
-
-
- },
-
- {
- field: 'actions',
- type: 'actions',
- minWidth: 30,
- headerClassName: 'datagrid--header',
- cellClassName: 'datagrid--cell',
- getActions: (params: GridRowParams) => {
-
- return [
-
-
-
,
- ]
- },
- },
- ];
-};
+ const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
+
+ return [
+ {
+ field: 'name',
+ headerName: 'Name',
+ flex: 1,
+ minWidth: 180,
+ filterable: false,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+ editable: hasUpdatePermission,
+ },
+ {
+ field: 'linkedTenants',
+ headerName: 'Linked tenants',
+ flex: 1.2,
+ minWidth: 220,
+ filterable: false,
+ sortable: false,
+ editable: false,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+ renderCell: (params: any) => (
+
+
+
+ ),
+ },
+ {
+ field: 'actions',
+ type: 'actions',
+ minWidth: 30,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+ getActions: (params: GridRowParams) => [
+
+
+
,
+ ],
+ },
+ ]
+}
diff --git a/frontend/src/components/Tenants/CardTenants.tsx b/frontend/src/components/Tenants/CardTenants.tsx
index 02b8513..f78fc19 100644
--- a/frontend/src/components/Tenants/CardTenants.tsx
+++ b/frontend/src/components/Tenants/CardTenants.tsx
@@ -55,12 +55,17 @@ const CardTenants = ({
}`}
>
-
-
-
- {item.name}
-
-
+
+
+
+ {item.name}
+
+
+ {Array.isArray(item.organizations) && item.organizations.length
+ ? `${item.organizations.length} linked organization${item.organizations.length === 1 ? '' : 's'}`
+ : 'No linked organizations'}
+
+
- Organizations
-
-
- { dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}
-
+
+
Linked organizations
+
+ {Array.isArray(item.organizations) && item.organizations.length ? (
+ <>
+
+ {item.organizations.length} linked
+
+ {item.organizations.map((organization: any) => (
+
+ {organization.name}
+
+ ))}
+ >
+ ) : (
+ No linked organizations
+ )}
diff --git a/frontend/src/components/Tenants/TableTenants.tsx b/frontend/src/components/Tenants/TableTenants.tsx
index adfd504..151a9cf 100644
--- a/frontend/src/components/Tenants/TableTenants.tsx
+++ b/frontend/src/components/Tenants/TableTenants.tsx
@@ -211,7 +211,7 @@ const TableSampleTenants = ({ filterItems, setFilterItems, filters, showGrid })
`datagrid--row`}
diff --git a/frontend/src/components/Tenants/configureTenantsCols.tsx b/frontend/src/components/Tenants/configureTenantsCols.tsx
index 3a4f875..fb86d06 100644
--- a/frontend/src/components/Tenants/configureTenantsCols.tsx
+++ b/frontend/src/components/Tenants/configureTenantsCols.tsx
@@ -149,9 +149,9 @@ export const loadColumns = async (
{
field: 'organizations',
- headerName: 'Organizations',
- flex: 1,
- minWidth: 120,
+ headerName: 'Linked organizations',
+ flex: 1.2,
+ minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
@@ -161,6 +161,42 @@ export const loadColumns = async (
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.organizationsManyListFormatter(value).join(', '),
+ renderCell: (params: any) => {
+ const organizations = Array.isArray(params.value) ? params.value : [];
+
+ if (!organizations.length) {
+ return (
+
+ No linked organizations
+
+ );
+ }
+
+ const preview = organizations.slice(0, 2);
+ const remainingCount = organizations.length - preview.length;
+
+ return (
+
+
+ {organizations.length} linked
+
+ {preview.map((organization: any) => (
+
+ {organization.name}
+
+ ))}
+ {remainingCount > 0 ? (
+
+ +{remainingCount} more
+
+ ) : null}
+
+ );
+ },
renderEditCell: (params) => (
),
diff --git a/frontend/src/helpers/entityVisibility.ts b/frontend/src/helpers/entityVisibility.ts
index 3005079..e42b35d 100644
--- a/frontend/src/helpers/entityVisibility.ts
+++ b/frontend/src/helpers/entityVisibility.ts
@@ -48,14 +48,46 @@ const hiddenFieldsByEntityAndLane: Record>>
const isRangeFilter = (filter?: FilterDefinition) => Boolean(filter?.number || filter?.date)
-const getFilterDisplayLabel = (filter?: FilterDefinition) => {
- const rawLabel = `${filter?.label ?? filter?.title ?? ""}`.trim()
+const getHumanizedFieldName = (value?: string) => {
+ if (!value) {
+ return ''
+ }
- if (!rawLabel) {
+ return value
+ .replace(/_/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+const cleanupFilterDisplayLabel = (value: string) => {
+ if (!value) {
return 'this field'
}
- return rawLabel.charAt(0).toLowerCase() + rawLabel.slice(1)
+ const cleanedValue = value
+ .replace(/\bids\b/gi, '')
+ .replace(/\bid\b$/i, '')
+ .replace(/\bat\b$/i, '')
+ .replace(/\burl\b/gi, 'URL')
+ .replace(/\bapi\b/gi, 'API')
+ .replace(/\bcheck in\b/gi, 'check-in')
+ .replace(/\bcheck out\b/gi, 'check-out')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ if (!cleanedValue) {
+ return 'this field'
+ }
+
+ return cleanedValue.charAt(0).toLowerCase() + cleanedValue.slice(1)
+}
+
+const getFilterDisplayLabel = (filter?: FilterDefinition) => {
+ const rawTitle = getHumanizedFieldName(filter?.title)
+ const rawLabel = getHumanizedFieldName(filter?.label)
+ const displaySource = rawTitle || rawLabel
+
+ return cleanupFilterDisplayLabel(displaySource)
}
export const getHiddenFieldsForRole = (entityName: string, roleLane: RoleLane) => {
diff --git a/frontend/src/helpers/organizationTenants.ts b/frontend/src/helpers/organizationTenants.ts
new file mode 100644
index 0000000..a7b7099
--- /dev/null
+++ b/frontend/src/helpers/organizationTenants.ts
@@ -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
+
+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()
+
+ ;[...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 => {
+ 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 => {
+ 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
+ }, {})
+}
diff --git a/frontend/src/pages/organizations/organizations-edit.tsx b/frontend/src/pages/organizations/organizations-edit.tsx
index 1daa306..d880993 100644
--- a/frontend/src/pages/organizations/organizations-edit.tsx
+++ b/frontend/src/pages/organizations/organizations-edit.tsx
@@ -1,171 +1,320 @@
-import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
+import { mdiChartTimelineVariant, mdiDomain, mdiOfficeBuildingCogOutline } from '@mdi/js'
import Head from 'next/head'
-import React, { ReactElement, useEffect, useState } from 'react'
-import DatePicker from "react-datepicker";
-import "react-datepicker/dist/react-datepicker.css";
-import dayjs from "dayjs";
+import React, { ReactElement, useEffect, useMemo, useState } from 'react'
+import { Field, Form, Formik } from 'formik'
+import { useRouter } from 'next/router'
+import BaseButton from '../../components/BaseButton'
+import BaseButtons from '../../components/BaseButtons'
+import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
-import LayoutAuthenticated from '../../layouts/Authenticated'
+import ConnectedEntityCard from '../../components/ConnectedEntityCard'
+import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
+import FormField from '../../components/FormField'
+import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
-
-import { Field, Form, Formik } from 'formik'
-import FormField from '../../components/FormField'
-import BaseDivider from '../../components/BaseDivider'
-import BaseButtons from '../../components/BaseButtons'
-import BaseButton from '../../components/BaseButton'
-import FormCheckRadio from '../../components/FormCheckRadio'
-import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
-import FormFilePicker from '../../components/FormFilePicker'
-import FormImagePicker from '../../components/FormImagePicker'
-import { SelectField } from "../../components/SelectField";
-import { SelectFieldMany } from "../../components/SelectFieldMany";
-import { SwitchField } from '../../components/SwitchField'
-import {RichTextField} from "../../components/RichTextField";
-
-import { update, fetch } from '../../stores/organizations/organizationsSlice'
+import {
+ emptyOrganizationTenantSummary,
+ getTenantManageHref,
+ getTenantSetupHref,
+ getTenantViewHref,
+ loadLinkedTenantSummary,
+} from '../../helpers/organizationTenants'
+import { hasPermission } from '../../helpers/userPermissions'
+import LayoutAuthenticated from '../../layouts/Authenticated'
+import { fetch, update } from '../../stores/organizations/organizationsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
-import { useRouter } from 'next/router'
-import {saveFile} from "../../helpers/fileSaver";
-import dataFormatter from '../../helpers/dataFormatter';
-import ImageField from "../../components/ImageField";
-
-import {hasPermission} from "../../helpers/userPermissions";
-
+const emptyOrganization = {
+ name: '',
+}
const EditOrganizationsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
- const initVals = {
-
-
- 'name': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- const [initialValues, setInitialValues] = useState(initVals)
-
const { organizations } = useAppSelector((state) => state.organizations)
-
- const { currentUser } = useAppSelector((state) => state.auth);
-
+ const { currentUser } = useAppSelector((state) => state.auth)
- const { id } = router.query
+ const [initialValues, setInitialValues] = useState(emptyOrganization)
+ const [submitMode, setSubmitMode] = useState<'list' | 'tenant'>('list')
+ const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
+
+ const organizationId = useMemo(
+ () => (typeof router.query.id === 'string' ? router.query.id : ''),
+ [router.query.id],
+ )
+ const tenantIdFromQuery = useMemo(
+ () => (typeof router.query.tenantId === 'string' ? router.query.tenantId : ''),
+ [router.query.tenantId],
+ )
+ const tenantNameFromQuery = useMemo(
+ () => (typeof router.query.tenantName === 'string' ? router.query.tenantName : ''),
+ [router.query.tenantName],
+ )
+
+ const canReadTenants = hasPermission(currentUser, 'READ_TENANTS')
+ const canUpdateTenants = hasPermission(currentUser, 'UPDATE_TENANTS')
+ const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
useEffect(() => {
- dispatch(fetch({ id: id }))
- }, [id])
+ if (!organizationId) return
+
+ dispatch(fetch({ id: organizationId }))
+ }, [dispatch, organizationId])
useEffect(() => {
- if (typeof organizations === 'object') {
- setInitialValues(organizations)
+ if (!organizations || typeof organizations !== 'object' || Array.isArray(organizations)) {
+ return
}
+
+ setInitialValues({
+ name: organizations.name || '',
+ })
}, [organizations])
useEffect(() => {
- if (typeof organizations === 'object') {
- const newInitialVal = {...initVals};
- Object.keys(initVals).forEach(el => newInitialVal[el] = (organizations)[el])
- setInitialValues(newInitialVal);
- }
- }, [organizations])
+ if (!organizationId) {
+ setLinkedTenantSummary(emptyOrganizationTenantSummary)
+ return
+ }
+
+ let isActive = true
+
+ loadLinkedTenantSummary(organizationId)
+ .then((summary) => {
+ if (isActive) {
+ setLinkedTenantSummary(summary)
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load linked tenants for organization edit:', error)
+
+ if (isActive) {
+ setLinkedTenantSummary(emptyOrganizationTenantSummary)
+ }
+ })
+
+ return () => {
+ isActive = false
+ }
+ }, [organizationId])
const handleSubmit = async (data) => {
- await dispatch(update({ id: id, data }))
+ const resultAction = await dispatch(update({ id: organizationId, data }))
+
+ if (!update.fulfilled.match(resultAction)) {
+ return
+ }
+
+ if (submitMode === 'tenant' && organizationId) {
+ await router.push(getTenantSetupHref(organizationId, data.name || initialValues.name || ''))
+ return
+ }
+
await router.push('/organizations/organizations-list')
}
return (
<>
- {getPageTitle('Edit organizations')}
+ {getPageTitle('Edit organization')}
-
- {''}
+
+ {''}
+
+
+ }
+ >
+ Organizations stay lightweight here. Tenant settings like slug, domain, timezone, currency, and activation
+ still live under Tenants , so you can jump straight into tenant setup after saving changes.
+
+
- handleSubmit(values)}
- >
-
+ )
+ }}
@@ -174,15 +323,7 @@ const EditOrganizationsPage = () => {
}
EditOrganizationsPage.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
+ return {page}
}
export default EditOrganizationsPage
diff --git a/frontend/src/pages/organizations/organizations-new.tsx b/frontend/src/pages/organizations/organizations-new.tsx
index b61b5b7..7b600d6 100644
--- a/frontend/src/pages/organizations/organizations-new.tsx
+++ b/frontend/src/pages/organizations/organizations-new.tsx
@@ -1,124 +1,121 @@
-import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
+import { mdiChartTimelineVariant, mdiDomain, mdiOfficeBuildingCogOutline } from '@mdi/js'
import Head from 'next/head'
-import React, { ReactElement } from 'react'
+import React, { ReactElement, useState } from 'react'
+import { Field, Form, Formik } from 'formik'
+import { useRouter } from 'next/router'
+
+import BaseButton from '../../components/BaseButton'
+import BaseButtons from '../../components/BaseButtons'
+import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
-import LayoutAuthenticated from '../../layouts/Authenticated'
+import FormField from '../../components/FormField'
+import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
-
-import { Field, Form, Formik } from 'formik'
-import FormField from '../../components/FormField'
-import BaseDivider from '../../components/BaseDivider'
-import BaseButtons from '../../components/BaseButtons'
-import BaseButton from '../../components/BaseButton'
-import FormCheckRadio from '../../components/FormCheckRadio'
-import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
-import FormFilePicker from '../../components/FormFilePicker'
-import FormImagePicker from '../../components/FormImagePicker'
-import { SwitchField } from '../../components/SwitchField'
-
-import { SelectField } from '../../components/SelectField'
-import { SelectFieldMany } from "../../components/SelectFieldMany";
-import {RichTextField} from "../../components/RichTextField";
-
+import { getTenantSetupHref } from '../../helpers/organizationTenants'
+import LayoutAuthenticated from '../../layouts/Authenticated'
import { create } from '../../stores/organizations/organizationsSlice'
import { useAppDispatch } from '../../stores/hooks'
-import { useRouter } from 'next/router'
-import moment from 'moment';
const initialValues = {
-
-
- name: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ name: '',
}
-
const OrganizationsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
-
-
-
+ const [submitMode, setSubmitMode] = useState<'list' | 'tenant'>('list')
const handleSubmit = async (data) => {
- await dispatch(create(data))
+ const resultAction = await dispatch(create(data))
+
+ if (!create.fulfilled.match(resultAction)) {
+ return
+ }
+
+ const createdOrganization = resultAction.payload
+
+ if (submitMode === 'tenant' && createdOrganization?.id) {
+ await router.push(getTenantSetupHref(createdOrganization.id, createdOrganization.name || data.name || ''))
+ return
+ }
+
await router.push('/organizations/organizations-list')
}
+
return (
<>
- {getPageTitle('New Item')}
+ {getPageTitle('New organization')}
-
- {''}
+
+ {''}
+
+ }
+ >
+ Organizations in this app are lightweight workspace records. Tenant settings like slug, domain, timezone,
+ currency, and activation live under Tenants .
+
+
- handleSubmit(values)}
- >
+ handleSubmit(values)}>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
What happens next?
+
+ After saving, you can go straight into tenant setup to attach this organization and fill in the
+ tenant’s business settings.
+
+
+
+
+
+
+
+ setSubmitMode('tenant')}
+ />
+ setSubmitMode('list')} />
- router.push('/organizations/organizations-list')}/>
+ router.push('/organizations/organizations-list')}
+ />
@@ -129,15 +126,7 @@ const OrganizationsNew = () => {
}
OrganizationsNew.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
+ return {page}
}
export default OrganizationsNew
diff --git a/frontend/src/pages/organizations/organizations-view.tsx b/frontend/src/pages/organizations/organizations-view.tsx
index 4d34176..f2f4f76 100644
--- a/frontend/src/pages/organizations/organizations-view.tsx
+++ b/frontend/src/pages/organizations/organizations-view.tsx
@@ -1,4 +1,4 @@
-import React, { ReactElement, useEffect } from 'react';
+import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
@@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
+import ConnectedEntityCard from '../../components/ConnectedEntityCard'
+import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
@@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import {hasPermission} from "../../helpers/userPermissions";
+import { emptyOrganizationTenantSummary, getOrganizationManageHref, getTenantManageHref, getTenantSetupHref, getTenantViewHref, loadLinkedTenantSummary } from '../../helpers/organizationTenants'
const OrganizationsView = () => {
@@ -29,19 +32,45 @@ const OrganizationsView = () => {
const { organizations } = useAppSelector((state) => state.organizations)
const { currentUser } = useAppSelector((state) => state.auth);
-
+ const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const { id } = router.query;
-
- function removeLastCharacter(str) {
- console.log(str,`str`)
- return str.slice(0, -1);
- }
+ const tenantIdFromQuery = typeof router.query.tenantId === 'string' ? router.query.tenantId : '';
+ const tenantNameFromQuery = typeof router.query.tenantName === 'string' ? router.query.tenantName : '';
+ const canReadTenants = hasPermission(currentUser, 'READ_TENANTS');
+ const canUpdateTenants = hasPermission(currentUser, 'UPDATE_TENANTS');
+ const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [];
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
+ useEffect(() => {
+ if (!id || typeof id !== 'string') {
+ setLinkedTenantSummary(emptyOrganizationTenantSummary)
+ return
+ }
+
+ let isActive = true
+
+ loadLinkedTenantSummary(id)
+ .then((summary) => {
+ if (isActive) {
+ setLinkedTenantSummary(summary)
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load linked tenants for organization view:', error)
+
+ if (isActive) {
+ setLinkedTenantSummary(emptyOrganizationTenantSummary)
+ }
+ })
+
+ return () => {
+ isActive = false
+ }
+ }, [id]);
return (
<>
@@ -49,14 +78,27 @@ const OrganizationsView = () => {
{getPageTitle('View organizations')}
-
-
+
+
+
+ {tenantIdFromQuery ? (
+
+ Context preserved from the tenant flow.
+
+ ) : null}
+
+
@@ -64,6 +106,68 @@ const OrganizationsView = () => {
Name
{organizations?.name}
+
+
+
+
+
Linked tenants
+
+ {linkedTenantSummary.count
+ ? `${linkedTenantSummary.count} tenant${linkedTenantSummary.count === 1 ? '' : 's'} currently linked to this organization.`
+ : 'This organization is not linked to a tenant yet.'}
+
+
+
+
+
+ {linkedTenants.length ? (
+
+ {linkedTenants.map((tenant: any) => (
+
+ ))}
+
+ ) : (
+
+ Create a tenant from here to preselect this organization automatically, then save the tenant to establish the link.
+
+ )}
+
diff --git a/frontend/src/pages/tenants/tenants-edit.tsx b/frontend/src/pages/tenants/tenants-edit.tsx
index 1367a92..58b0c63 100644
--- a/frontend/src/pages/tenants/tenants-edit.tsx
+++ b/frontend/src/pages/tenants/tenants-edit.tsx
@@ -1,941 +1,443 @@
-import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
+import { mdiChartTimelineVariant, mdiOfficeBuildingCogOutline } from '@mdi/js'
+import axios from 'axios'
import Head from 'next/head'
-import React, { ReactElement, useEffect, useState } from 'react'
-import DatePicker from "react-datepicker";
-import "react-datepicker/dist/react-datepicker.css";
-import dayjs from "dayjs";
+import React, { ReactElement, useEffect, useMemo, useState } from 'react'
+import { Field, Form, Formik } from 'formik'
+import { useRouter } from 'next/router'
+import BaseButton from '../../components/BaseButton'
+import BaseButtons from '../../components/BaseButtons'
+import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
-import LayoutAuthenticated from '../../layouts/Authenticated'
+import ConnectedEntityCard from '../../components/ConnectedEntityCard'
+import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
+import FormField from '../../components/FormField'
+import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
-import { getPageTitle } from '../../config'
-
-import { Field, Form, Formik } from 'formik'
-import FormField from '../../components/FormField'
-import BaseDivider from '../../components/BaseDivider'
-import BaseButtons from '../../components/BaseButtons'
-import BaseButton from '../../components/BaseButton'
-import FormCheckRadio from '../../components/FormCheckRadio'
-import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
-import FormFilePicker from '../../components/FormFilePicker'
-import FormImagePicker from '../../components/FormImagePicker'
-import { SelectField } from "../../components/SelectField";
-import { SelectFieldMany } from "../../components/SelectFieldMany";
+import { SelectFieldMany } from '../../components/SelectFieldMany'
import { SwitchField } from '../../components/SwitchField'
-import {RichTextField} from "../../components/RichTextField";
-
-import { update, fetch } from '../../stores/tenants/tenantsSlice'
+import { getPageTitle } from '../../config'
+import {
+ getOrganizationManageHref,
+ getOrganizationViewHref,
+ getTenantSetupHref,
+ mergeEntityOptions,
+} from '../../helpers/organizationTenants'
+import { hasPermission } from '../../helpers/userPermissions'
+import LayoutAuthenticated from '../../layouts/Authenticated'
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";
-
+import { fetch, update } from '../../stores/tenants/tenantsSlice'
+const defaultInitialValues = {
+ name: '',
+ slug: '',
+ legal_name: '',
+ primary_domain: '',
+ timezone: '',
+ default_currency: '',
+ is_active: false,
+ organizations: [],
+ properties: [],
+ audit_logs: [],
+}
const EditTenantsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
- const initVals = {
-
-
- 'name': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 'slug': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 'legal_name': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 'primary_domain': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 'timezone': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 'default_currency': '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- is_active: false,
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- organizations: [],
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- properties: [],
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- audit_logs: [],
-
-
-
- }
- const [initialValues, setInitialValues] = useState(initVals)
-
const { tenants } = useAppSelector((state) => state.tenants)
-
- const { currentUser } = useAppSelector((state) => state.auth);
-
+ const { currentUser } = useAppSelector((state) => state.auth)
+
+ const [baseInitialValues, setBaseInitialValues] = useState(defaultInitialValues)
+ const [organizationPrefillOptions, setOrganizationPrefillOptions] = useState([])
+ const [linkedOrganization, setLinkedOrganization] = useState<{ id: string; name: string } | null>(null)
+ const [prefillError, setPrefillError] = useState('')
const { id } = router.query
+ const tenantId = useMemo(() => (typeof id === 'string' ? id : ''), [id])
+ 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],
+ )
+
+ const canReadOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
+ const canUpdateOrganizations = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
+
+ const initialValues = useMemo(
+ () => ({
+ ...baseInitialValues,
+ organizations: mergeEntityOptions(baseInitialValues.organizations, organizationPrefillOptions),
+ }),
+ [baseInitialValues, organizationPrefillOptions],
+ )
useEffect(() => {
- dispatch(fetch({ id: id }))
- }, [id])
-
- useEffect(() => {
- if (typeof tenants === 'object') {
- setInitialValues(tenants)
+ if (!id) {
+ return
}
+
+ dispatch(fetch({ id }))
+ }, [dispatch, id])
+
+ useEffect(() => {
+ if (!tenants || typeof tenants !== 'object' || Array.isArray(tenants)) {
+ return
+ }
+
+ setBaseInitialValues({
+ ...defaultInitialValues,
+ name: tenants.name || '',
+ slug: tenants.slug || '',
+ legal_name: tenants.legal_name || '',
+ primary_domain: tenants.primary_domain || '',
+ timezone: tenants.timezone || '',
+ default_currency: tenants.default_currency || '',
+ is_active: Boolean(tenants.is_active),
+ organizations: Array.isArray(tenants.organizations) ? tenants.organizations : [],
+ properties: Array.isArray(tenants.properties) ? tenants.properties : [],
+ audit_logs: Array.isArray(tenants.audit_logs) ? tenants.audit_logs : [],
+ })
}, [tenants])
useEffect(() => {
- if (typeof tenants === 'object') {
- const newInitialVal = {...initVals};
- Object.keys(initVals).forEach(el => newInitialVal[el] = (tenants)[el])
- setInitialValues(newInitialVal);
+ if (!router.isReady) {
+ return
+ }
+
+ if (!organizationId) {
+ setLinkedOrganization(null)
+ setOrganizationPrefillOptions([])
+ setPrefillError('')
+ 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 }] : []
+
+ setLinkedOrganization(data?.id ? { id: data.id, name: data.name } : null)
+ setOrganizationPrefillOptions(organizationOption)
+ } catch (error) {
+ console.error('Failed to prefill tenant edit from organization:', error)
+
+ if (!isActive) {
+ return
+ }
+
+ setLinkedOrganization(
+ organizationId
+ ? {
+ id: organizationId,
+ name: organizationNameFromQuery || 'Selected organization',
+ }
+ : null,
+ )
+ setOrganizationPrefillOptions(
+ organizationId
+ ? [
+ {
+ id: organizationId,
+ name: organizationNameFromQuery || 'Selected organization',
+ },
+ ]
+ : [],
+ )
+ setPrefillError('We could not load the full organization record, but you can still keep or remove the preselected organization below.')
}
- }, [tenants])
+ }
+
+ loadOrganization()
+
+ return () => {
+ isActive = false
+ }
+ }, [organizationId, organizationNameFromQuery, router.isReady])
const handleSubmit = async (data) => {
- await dispatch(update({ id: id, data }))
+ const resultAction = await dispatch(update({ id, data }))
+
+ if (!update.fulfilled.match(resultAction)) {
+ return
+ }
+
await router.push('/tenants/tenants-list')
}
+ const linkedOrganizations = Array.isArray(initialValues.organizations) ? initialValues.organizations : []
+
return (
<>
- {getPageTitle('Edit tenants')}
+ {getPageTitle('Edit tenant')}
-
- {''}
+
+ {''}
+
+ }
+ >
+ Keep tenant-wide settings here, and use the organizations field below to control which workspaces belong to
+ this tenant.
+
+
- handleSubmit(values)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {linkedOrganization ? (
+
+
Organization ready to link: {linkedOrganization.name}
+
+ This organization is preselected below so you can attach it to this tenant in the same save.
+
+
+
+
+
+
+ ) : null}
+
+ {prefillError ? (
+
+ {prefillError}
+
+ ) : null}
+
+ handleSubmit(values)}>
+ {({ values }) => {
+ const tenantNameForLinks = values.name || initialValues.name || tenants?.name || ''
+
+ return (
+
+
+ Tenants hold shared settings, while organizations are the workspaces connected to them.
+ {linkedOrganizations.length
+ ? ` ${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} currently linked.`
+ : ' No organizations are linked yet.'}
+ {linkedOrganization ? ` ${linkedOrganization.name} is already preselected from the organization flow.` : ''}
+ >
+ }
+ actions={[
+ {
+ href: '/organizations/organizations-list',
+ label: 'Browse organizations',
+ color: 'info',
+ outline: true,
+ },
+ ...(linkedOrganization
+ ? canUpdateOrganizations
+ ? [
+ {
+ href: getOrganizationManageHref(linkedOrganization.id, tenantId, tenantNameForLinks),
+ label: 'Back to organization',
+ color: 'white',
+ outline: true,
+ },
+ ]
+ : canReadOrganizations
+ ? [
+ {
+ href: getOrganizationViewHref(linkedOrganization.id, tenantId, tenantNameForLinks),
+ label: 'View organization',
+ color: 'white',
+ outline: true,
+ },
+ ]
+ : []
+ : []),
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Linked organizations
+
+ {linkedOrganizations.length
+ ? `${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} will stay attached to this tenant after you save.`
+ : 'No organizations are currently linked. Add one below when this tenant should own a workspace.'}
+
+
+
+
+
+ {linkedOrganizations.length ? (
+
+ {linkedOrganizations.map((organization: any) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- router.push('/tenants/tenants-list')}/>
-
-
+
+
+
+
+ router.push('/tenants/tenants-list')}
+ />
+
+
+ )
+ }}
@@ -944,15 +446,7 @@ const EditTenantsPage = () => {
}
EditTenantsPage.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
+ return {page}
}
export default EditTenantsPage
diff --git a/frontend/src/pages/tenants/tenants-list.tsx b/frontend/src/pages/tenants/tenants-list.tsx
index d2b03fc..10fe8c6 100644
--- a/frontend/src/pages/tenants/tenants-list.tsx
+++ b/frontend/src/pages/tenants/tenants-list.tsx
@@ -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'},
]);
diff --git a/frontend/src/pages/tenants/tenants-new.tsx b/frontend/src/pages/tenants/tenants-new.tsx
index 99cea4d..c8ed71c 100644
--- a/frontend/src/pages/tenants/tenants-new.tsx
+++ b/frontend/src/pages/tenants/tenants-new.tsx
@@ -1,585 +1,259 @@
-import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
+import { mdiChartTimelineVariant, mdiOfficeBuildingCogOutline } from '@mdi/js'
+import axios from 'axios'
import Head from 'next/head'
-import React, { ReactElement } from 'react'
+import React, { ReactElement, useEffect, useMemo, useState } from 'react'
+import { Field, Form, Formik } from 'formik'
+import { useRouter } from 'next/router'
+
+import BaseButton from '../../components/BaseButton'
+import BaseButtons from '../../components/BaseButtons'
+import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox'
-import LayoutAuthenticated from '../../layouts/Authenticated'
+import FormField from '../../components/FormField'
+import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
-import { getPageTitle } from '../../config'
-
-import { Field, Form, Formik } from 'formik'
-import FormField from '../../components/FormField'
-import BaseDivider from '../../components/BaseDivider'
-import BaseButtons from '../../components/BaseButtons'
-import BaseButton from '../../components/BaseButton'
-import FormCheckRadio from '../../components/FormCheckRadio'
-import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
-import FormFilePicker from '../../components/FormFilePicker'
-import FormImagePicker from '../../components/FormImagePicker'
+import { SelectFieldMany } from '../../components/SelectFieldMany'
import { SwitchField } from '../../components/SwitchField'
-
-import { SelectField } from '../../components/SelectField'
-import { SelectFieldMany } from "../../components/SelectFieldMany";
-import {RichTextField} from "../../components/RichTextField";
-
-import { create } from '../../stores/tenants/tenantsSlice'
+import { getPageTitle } from '../../config'
+import { getOrganizationManageHref } from '../../helpers/organizationTenants'
+import LayoutAuthenticated from '../../layouts/Authenticated'
import { useAppDispatch } from '../../stores/hooks'
-import { useRouter } from 'next/router'
-import moment from 'moment';
+import { create } from '../../stores/tenants/tenantsSlice'
-const initialValues = {
-
-
- name: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- slug: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- legal_name: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- primary_domain: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- timezone: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- default_currency: '',
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- is_active: false,
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- organizations: [],
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- properties: [],
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- audit_logs: [],
-
-
+const defaultInitialValues = {
+ name: '',
+ slug: '',
+ legal_name: '',
+ primary_domain: '',
+ timezone: '',
+ default_currency: '',
+ is_active: false,
+ organizations: [],
+ properties: [],
+ audit_logs: [],
}
-
const TenantsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
+ const [initialValues, setInitialValues] = useState(defaultInitialValues)
+ const [linkedOrganization, setLinkedOrganization] = useState<{ id: string; name: string } | null>(null)
+ const [prefillError, setPrefillError] = useState('')
-
-
+ const organizationId = useMemo(
+ () => (typeof router.query.organizationId === 'string' ? router.query.organizationId : ''),
+ [router.query.organizationId],
+ )
+
+ const organizationNameFromQuery = useMemo(
+ () => (typeof router.query.organizationName === 'string' ? router.query.organizationName : ''),
+ [router.query.organizationName],
+ )
+
+ useEffect(() => {
+ if (!router.isReady) {
+ return
+ }
+
+ if (!organizationId) {
+ setLinkedOrganization(null)
+ setPrefillError('')
+ setInitialValues(defaultInitialValues)
+ return
+ }
+
+ let isActive = true
+
+ const loadOrganization = async () => {
+ try {
+ setPrefillError('')
+
+ const { data } = await axios.get(`/organizations/${organizationId}`)
+
+ if (!isActive) {
+ return
+ }
+
+ const organizationOption = data?.id ? [{ id: data.id, name: data.name }] : []
+ const preferredName = data?.name || organizationNameFromQuery
+
+ setLinkedOrganization(data?.id ? { id: data.id, name: data.name } : null)
+ setInitialValues({
+ ...defaultInitialValues,
+ name: preferredName,
+ organizations: organizationOption,
+ })
+ } catch (error) {
+ console.error('Failed to prefill tenant setup from organization:', error)
+
+ if (!isActive) {
+ return
+ }
+
+ setLinkedOrganization(
+ organizationId
+ ? {
+ id: organizationId,
+ name: organizationNameFromQuery || 'Selected organization',
+ }
+ : null,
+ )
+ setPrefillError('We could not load the full organization record, but you can still choose it manually below.')
+ setInitialValues({
+ ...defaultInitialValues,
+ name: organizationNameFromQuery,
+ })
+ }
+ }
+
+ loadOrganization()
+
+ return () => {
+ isActive = false
+ }
+ }, [organizationId, organizationNameFromQuery, router.isReady])
const handleSubmit = async (data) => {
- await dispatch(create(data))
+ const resultAction = await dispatch(create(data))
+
+ if (!create.fulfilled.match(resultAction)) {
+ return
+ }
+
await router.push('/tenants/tenants-list')
}
+
return (
<>
- {getPageTitle('New Item')}
+ {getPageTitle('New tenant')}
-
- {''}
+
+ {''}
+
+ }
+ >
+ 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.
+
+
- handleSubmit(values)}
- >
+ {linkedOrganization && (
+
+
Starting from organization: {linkedOrganization.name}
+
+ This organization will be preselected below so you can complete tenant setup in one flow.
+
+
+
+
+
+ )}
+
+ {prefillError ? (
+
+ {prefillError}
+
+ ) : null}
+
+ handleSubmit(values)}>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
- router.push('/tenants/tenants-list')}/>
+ router.push('/tenants/tenants-list')}
+ />
@@ -590,15 +264,7 @@ const TenantsNew = () => {
}
TenantsNew.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
+ return {page}
}
export default TenantsNew
diff --git a/frontend/src/pages/tenants/tenants-view.tsx b/frontend/src/pages/tenants/tenants-view.tsx
index 7bd61e6..fbc565c 100644
--- a/frontend/src/pages/tenants/tenants-view.tsx
+++ b/frontend/src/pages/tenants/tenants-view.tsx
@@ -14,6 +14,8 @@ import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
+import ConnectedEntityCard from '../../components/ConnectedEntityCard'
+import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
@@ -21,6 +23,7 @@ import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import {hasPermission} from "../../helpers/userPermissions";
+import { getOrganizationManageHref, getOrganizationViewHref, getTenantManageHref } from '../../helpers/organizationTenants'
const TenantsView = () => {
@@ -32,9 +35,13 @@ const TenantsView = () => {
const { id } = router.query;
+ const organizationIdFromQuery = typeof router.query.organizationId === 'string' ? router.query.organizationId : '';
+ const organizationNameFromQuery = typeof router.query.organizationName === 'string' ? router.query.organizationName : '';
+ const linkedOrganizations = Array.isArray(tenants?.organizations) ? tenants.organizations : [];
+ const canReadOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS');
+ const canUpdateOrganizations = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS');
function removeLastCharacter(str) {
- console.log(str,`str`)
return str.slice(0, -1);
}
@@ -50,13 +57,26 @@ const TenantsView = () => {
-
+
+
+ {organizationIdFromQuery ? (
+
+ Context preserved from the organization flow.
+
+ ) : null}
+
+
@@ -275,164 +295,64 @@ const TenantsView = () => {
disabled
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<>
- Organizations
-
-
-
-
-
-
-
-
-
-
-
- Name
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {tenants.organizations && Array.isArray(tenants.organizations) &&
- tenants.organizations.map((item: any) => (
- router.push(`/organizations/organizations-view/?id=${item.id}`)}>
-
-
-
-
-
-
-
- { item.name }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
+
+
+
Linked organizations
+
+ {linkedOrganizations.length
+ ? `${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} currently attached to this tenant.`
+ : 'No organizations are linked to this tenant yet.'}
+
- {!tenants?.organizations?.length &&
No data
}
-
+
+
+ {linkedOrganizations.length ? (
+
+ {linkedOrganizations.map((item: any) => (
+
+ ))}
+
+ ) : (
+
+ Add organizations from the tenant edit form when this tenant should own one or more workspaces.
+
+ )}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<>
Properties