diff --git a/frontend/src/components/ConnectedEntityCard.tsx b/frontend/src/components/ConnectedEntityCard.tsx new file mode 100644 index 0000000..eb81249 --- /dev/null +++ b/frontend/src/components/ConnectedEntityCard.tsx @@ -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 ( +
+
+
+
+ + {entityLabel} + + + Linked record + +
+ +

+ {title || titleFallback} +

+ + {visibleDetails.length ? ( +
+ {visibleDetails.map((detail) => ( + + {detail.label}:{' '} + {detail.value} + + ))} +
+ ) : null} +
+ + {actions.length ? ( +
+ {actions.map((action) => ( + + ))} + {helperText ? ( +

+ {helperText} +

+ ) : null} +
+ ) : null} +
+
+ ) +} + +export type { ConnectedEntityCardAction, ConnectedEntityCardDetail } +export default ConnectedEntityCard diff --git a/frontend/src/components/ConnectedEntityNotice.tsx b/frontend/src/components/ConnectedEntityNotice.tsx new file mode 100644 index 0000000..a2b66ec --- /dev/null +++ b/frontend/src/components/ConnectedEntityNotice.tsx @@ -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 ( +
+ Opened from + {contextLabel} + {contextHref ? ( + + ) : null} +
+ ) + } + + return ( +
+
+
+

{title}

+ {contextLabel ? ( +
+ Opened from + {contextLabel} + {contextHref ? ( + + ) : null} +
+ ) : null} + {description ?
{description}
: null} +
+ {actions.length ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} +
+
+ ) +} + +export type { ConnectedEntityAction } +export default ConnectedEntityNotice diff --git a/frontend/src/components/Organizations/CardOrganizations.tsx b/frontend/src/components/Organizations/CardOrganizations.tsx index c11cc5b..8567c87 100644 --- a/frontend/src/components/Organizations/CardOrganizations.tsx +++ b/frontend/src/components/Organizations/CardOrganizations.tsx @@ -1,24 +1,22 @@ -import React from 'react'; -import ImageField from '../ImageField'; -import ListActionsPopover from '../ListActionsPopover'; -import { useAppSelector } from '../../stores/hooks'; -import dataFormatter from '../../helpers/dataFormatter'; -import { Pagination } from '../Pagination'; -import {saveFile} from "../../helpers/fileSaver"; -import LoadingSpinner from "../LoadingSpinner"; -import Link from 'next/link'; - -import {hasPermission} from "../../helpers/userPermissions"; +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import LinkedTenantsPreview from './LinkedTenantsPreview' +import ListActionsPopover from '../ListActionsPopover' +import LoadingSpinner from '../LoadingSpinner' +import { Pagination } from '../Pagination' +import { useAppSelector } from '../../stores/hooks' +import { hasPermission } from '../../helpers/userPermissions' +import { loadLinkedTenantSummaries, OrganizationTenantSummaryMap } from '../../helpers/organizationTenants' type Props = { - organizations: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; -}; + organizations: any[] + loading: boolean + onDelete: (id: string) => void + currentPage: number + numPages: number + onPageChange: (page: number) => void +} const CardOrganizations = ({ organizations, @@ -28,84 +26,102 @@ const CardOrganizations = ({ numPages, onPageChange, }: Props) => { - const asideScrollbarsStyle = useAppSelector( - (state) => state.style.asideScrollbarsStyle, - ); - const bgColor = useAppSelector((state) => state.style.cardsColor); - const darkMode = useAppSelector((state) => state.style.darkMode); - const corners = useAppSelector((state) => state.style.corners); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - - const currentUser = useAppSelector((state) => state.auth.currentUser); - const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS') - + const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) + const bgColor = useAppSelector((state) => state.style.cardsColor) + const darkMode = useAppSelector((state) => state.style.darkMode) + const corners = useAppSelector((state) => state.style.corners) + const focusRing = useAppSelector((state) => state.style.focusRingColor) + + const currentUser = useAppSelector((state) => state.auth.currentUser) + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS') + const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({}) + + 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 ( -
+
{loading && } -
    - {!loading && organizations.map((item, index) => ( -
  • + {!loading && + organizations.map((item) => ( +
  • - -
    - - - {item.name} + }`} + > +
    + + {item.name} - -
    - -
    -
    -
    - - -
    -
    Name
    -
    -
    - { item.name } -
    -
    +
    + +
    +
    +
    +
    +
    Name
    +
    +
    {item.name}
    +
    - - -
    -
  • - ))} +
    +
    Linked tenants
    +
    + +
    +
    + + + ))} {!loading && organizations.length === 0 && ( -
    -

    No data to display

    +
    +

    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)} - > -
+ handleSubmit(values)}> + {({ values }) => { + const organizationNameForLinks = values.name || initialValues.name || organizations?.name || '' + return ( + + + 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, + }, + ] + : [] + : []), + ]} + /> - - - - - +
+
+
+

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. Create or connect one from here to keep the workspace fully wired.'} +

+
+ +
- + {linkedTenants.length ? ( +
+ {linkedTenants.map((tenant: any) => ( + + ))} +
+ ) : ( +
+ Create a tenant from here to preselect this organization automatically, then save the tenant to establish the link. +
+ )} +
- + + + - + - +
+
+
+

Need tenant settings too?

+

+ Save this organization, then continue into tenant setup to link it and manage shared settings like + slug, domain, timezone, currency, and activation. +

+
+ +
+
- - - - - - - - - - - - - - - - - - - - - - - - router.push('/organizations/organizations-list')}/> - - + + setSubmitMode('tenant')} + /> + setSubmitMode('list')} + /> + + router.push('/organizations/organizations-list')} + /> + + + ) + }}
@@ -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

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {tenants.organizations && Array.isArray(tenants.organizations) && - tenants.organizations.map((item: any) => ( - router.push(`/organizations/organizations-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
Name
- { 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