Autosave: 20260404-190901

This commit is contained in:
Flatlogic Bot 2026-04-04 19:09:02 +00:00
parent eaaae3e8f7
commit beda38dd14
8 changed files with 2505 additions and 15 deletions

View File

@ -38,7 +38,8 @@
"sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4"
"tedious": "^18.2.4",
"xlsx": "^0.18.5"
},
"engines": {
"node": ">=18"

View File

@ -3,6 +3,7 @@ const { Op } = require('sequelize');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const PortfolioWorkbookImportService = require('../services/portfolioWorkbookImport');
const router = express.Router();
@ -13,6 +14,8 @@ const servicePermissions = ['READ_SERVICE_REQUESTS'];
const financePermissions = ['READ_INVOICES'];
const inventoryPermissions = ['READ_PROPERTIES', 'READ_UNITS'];
const accountPermissions = ['READ_ORGANIZATIONS'];
const portfolioImportReadPermissions = ['READ_TENANTS', 'READ_ORGANIZATIONS', 'READ_PROPERTIES', 'READ_UNIT_TYPES', 'READ_UNITS', 'CREATE_TENANTS', 'CREATE_ORGANIZATIONS', 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS'];
const portfolioImportCreatePermissions = ['CREATE_TENANTS', 'CREATE_ORGANIZATIONS', 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS'];
function getPermissionSet(currentUser) {
return new Set([
@ -57,6 +60,38 @@ function formatUserDisplay(user) {
return fullName || user.email || 'Assigned staff';
}
router.get(
'/portfolio-import-template',
wrapAsync(async (req, res) => {
if (!hasAnyPermission(req.currentUser, portfolioImportReadPermissions)) {
const error = new Error('You do not have permission to access the portfolio import template.');
error.code = 403;
throw error;
}
const workbookBuffer = PortfolioWorkbookImportService.getTemplateBuffer();
res.status(200);
res.attachment('portfolio-import-template.xlsx');
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.send(workbookBuffer);
}),
);
router.post(
'/portfolio-import',
wrapAsync(async (req, res) => {
if (!hasAnyPermission(req.currentUser, portfolioImportCreatePermissions)) {
const error = new Error('You do not have permission to run the portfolio workbook import.');
error.code = 403;
throw error;
}
const payload = await PortfolioWorkbookImportService.importWorkbook(req, res);
res.status(200).send(payload);
}),
);
async function getGroupedCounts(model, statusField, where) {
const rows = await model.findAll({
attributes: [

View File

@ -0,0 +1,713 @@
const path = require('path');
const XLSX = require('xlsx');
const db = require('../db/models');
const processFile = require('../middlewares/upload');
const TenantsDBApi = require('../db/api/tenants');
const OrganizationsDBApi = require('../db/api/organizations');
const PropertiesDBApi = require('../db/api/properties');
const UnitTypesDBApi = require('../db/api/unit_types');
const UnitsDBApi = require('../db/api/units');
const {
createBadRequestError,
loadPropertyContext,
normalizeInventoryImportRow,
resolveOrganizationReference,
resolvePropertyReference,
resolveTenantIdForOrganization,
resolveTenantReference,
resolveUnitTypeReference,
} = require('../db/api/inventoryContext');
const Op = db.Sequelize.Op;
const SUPPORTED_WORKBOOK_EXTENSIONS = new Set(['.xlsx', '.xls']);
const SHEET_DEFINITIONS = [
{
key: 'tenants',
label: 'Tenants',
aliases: ['tenants', 'tenant'],
allowedHeaders: [
'id',
'name',
'slug',
'legal_name',
'primary_domain',
'timezone',
'default_currency',
'is_active',
'importHash',
],
requiredHeaders: ['name'],
},
{
key: 'organizations',
label: 'Organizations',
aliases: ['organizations', 'organization'],
allowedHeaders: ['id', 'tenant', 'name', 'importHash'],
requiredHeaders: ['tenant', 'name'],
},
{
key: 'properties',
label: 'Properties',
aliases: ['properties', 'property'],
allowedHeaders: [
'id',
'tenant',
'organization',
'name',
'code',
'address',
'city',
'country',
'timezone',
'description',
'is_active',
'importHash',
],
requiredHeaders: ['organization', 'name'],
},
{
key: 'unit_types',
label: 'Unit Types',
aliases: ['unit_types', 'unit types', 'unit-types', 'unit type'],
allowedHeaders: [
'id',
'property',
'name',
'code',
'description',
'max_occupancy',
'bedrooms',
'bathrooms',
'minimum_stay_nights',
'size_sqm',
'base_nightly_rate',
'base_monthly_rate',
'importHash',
],
requiredHeaders: ['property', 'name'],
},
{
key: 'units',
label: 'Units',
aliases: ['units', 'unit'],
allowedHeaders: [
'id',
'property',
'unit_type',
'unit_number',
'floor',
'status',
'max_occupancy_override',
'notes',
'importHash',
],
requiredHeaders: ['property', 'unit_type', 'unit_number'],
},
];
const SHEET_DEFINITION_BY_KEY = SHEET_DEFINITIONS.reduce((accumulator, definition) => {
accumulator[definition.key] = definition;
return accumulator;
}, {});
const normalizeSheetName = (value = '') =>
String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
const isBlankValue = (value) => {
if (value === undefined || value === null) {
return true;
}
if (typeof value === 'string') {
return value.trim().length === 0;
}
return false;
};
const isBlankRow = (row = {}) => Object.values(row).every((value) => isBlankValue(value));
const normalizeRowValue = (value) => {
if (typeof value === 'string') {
return value.trim();
}
return value;
};
const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
if (!error) {
return error;
}
const prefixedMessage = `${sheetLabel} row ${rowNumber}: ${error.message}`;
if (typeof error.message === 'string' && !error.message.startsWith(`${sheetLabel} row ${rowNumber}:`)) {
error.message = prefixedMessage;
}
return error;
};
const validateWorkbookFile = (req) => {
if (!req.file || !req.file.buffer || !req.file.buffer.length) {
throw createBadRequestError('Workbook upload is empty.');
}
const extension = path.extname(req.file.originalname || '').toLowerCase();
if (!SUPPORTED_WORKBOOK_EXTENSIONS.has(extension)) {
throw createBadRequestError('Upload an Excel workbook in .xlsx or .xls format.');
}
};
const getWorksheetHeader = (worksheet) => {
const rows = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
blankrows: false,
raw: false,
});
const headerRow = rows.find((row) => Array.isArray(row) && row.some((cell) => !isBlankValue(cell)));
if (!headerRow) {
return [];
}
return headerRow.map((cell) => String(cell).trim()).filter((cell) => cell.length > 0);
};
const validateHeaders = (headers = [], definition) => {
if (!headers.length) {
return;
}
const missingHeaders = definition.requiredHeaders.filter((header) => !headers.includes(header));
const unknownHeaders = headers.filter((header) => !definition.allowedHeaders.includes(header));
if (!missingHeaders.length && !unknownHeaders.length) {
return;
}
const details = [];
if (missingHeaders.length) {
details.push(`Missing required columns: ${missingHeaders.join(', ')}`);
}
if (unknownHeaders.length) {
details.push(`Unexpected columns: ${unknownHeaders.join(', ')}`);
}
details.push(`Allowed columns: ${definition.allowedHeaders.join(', ')}`);
throw createBadRequestError(`${definition.label} sheet is invalid. ${details.join('. ')}`);
};
const getWorksheetRows = (worksheet) => {
const rawRows = XLSX.utils.sheet_to_json(worksheet, {
defval: '',
raw: false,
});
return rawRows
.map((row) => Object.entries(row).reduce((accumulator, [key, value]) => {
const normalizedKey = String(key).trim();
if (!normalizedKey) {
return accumulator;
}
accumulator[normalizedKey] = normalizeRowValue(value);
return accumulator;
}, {}))
.filter((row) => !isBlankRow(row));
};
const getWorkbookSheets = (workbook) => {
const matchedSheets = {};
for (const definition of SHEET_DEFINITIONS) {
const sheetName = workbook.SheetNames.find((name) => definition.aliases.includes(normalizeSheetName(name)));
if (!sheetName) {
matchedSheets[definition.key] = {
definition,
name: null,
headers: [],
rows: [],
};
continue;
}
const worksheet = workbook.Sheets[sheetName];
const headers = getWorksheetHeader(worksheet);
validateHeaders(headers, definition);
matchedSheets[definition.key] = {
definition,
name: sheetName,
headers,
rows: getWorksheetRows(worksheet),
};
}
return matchedSheets;
};
const getWorkbookTemplateBuffer = () => {
const workbook = XLSX.utils.book_new();
for (const definition of SHEET_DEFINITIONS) {
const worksheet = XLSX.utils.aoa_to_sheet([definition.allowedHeaders]);
XLSX.utils.book_append_sheet(workbook, worksheet, definition.key);
}
return XLSX.write(workbook, {
type: 'buffer',
bookType: 'xlsx',
});
};
const ensureWorkbookHasRows = (sheets) => {
const hasRows = Object.values(sheets).some((sheet) => sheet.rows.length > 0);
if (!hasRows) {
throw createBadRequestError('The workbook does not contain any import rows.');
}
};
const getPermissionSet = (currentUser) =>
new Set([
...((currentUser?.custom_permissions || []).map((permission) => permission.name)),
...((currentUser?.app_role_permissions || []).map((permission) => permission.name)),
]);
const ensurePermissionsForSheets = (currentUser, sheets) => {
if (currentUser?.app_role?.globalAccess) {
return;
}
const permissionSet = getPermissionSet(currentUser);
const requiredPermissions = [];
if (sheets.tenants.rows.length) {
requiredPermissions.push('CREATE_TENANTS');
}
if (sheets.organizations.rows.length) {
requiredPermissions.push('CREATE_ORGANIZATIONS');
}
if (sheets.properties.rows.length) {
requiredPermissions.push('CREATE_PROPERTIES');
}
if (sheets.unit_types.rows.length) {
requiredPermissions.push('CREATE_UNIT_TYPES');
}
if (sheets.units.rows.length) {
requiredPermissions.push('CREATE_UNITS');
}
const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission));
if (missingPermissions.length) {
throw createBadRequestError(`Missing import permissions: ${missingPermissions.join(', ')}`);
}
if (sheets.tenants.rows.length || sheets.organizations.rows.length) {
throw createBadRequestError('Tenant and organization sheets require a global admin workspace.');
}
};
const getOrCreateTenantId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name;
if (reference) {
const existingTenantId = await resolveTenantReference(reference, context.transaction);
if (existingTenantId) {
context.summary.tenants.reused += 1;
return existingTenantId;
}
}
if (!normalizedRow.name) {
throw createBadRequestError('Tenant name is required.');
}
const createdTenant = await TenantsDBApi.create(normalizedRow, {
currentUser: context.currentUser,
transaction: context.transaction,
});
context.summary.tenants.created += 1;
return createdTenant.id;
};
const linkOrganizationToTenant = async (tenantId, organizationId, transaction) => {
if (!tenantId || !organizationId) {
return;
}
const tenant = await db.tenants.findByPk(tenantId, { transaction });
if (!tenant) {
throw createBadRequestError('Selected tenant was not found.');
}
const linkedOrganizations = await tenant.getOrganizations({ transaction });
const linkedOrganizationIds = new Set((linkedOrganizations || []).map((item) => item.id));
if (linkedOrganizationIds.has(organizationId)) {
return;
}
linkedOrganizationIds.add(organizationId);
await tenant.setOrganizations(Array.from(linkedOrganizationIds), { transaction });
};
const getOrCreateOrganizationId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
let tenantId = null;
if (normalizedRow.tenant) {
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction);
if (!tenantId) {
throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`);
}
}
const reference = normalizedRow.id || normalizedRow.name;
if (reference) {
const existingOrganizationId = await resolveOrganizationReference(reference, {
tenantId,
transaction: context.transaction,
});
if (existingOrganizationId) {
await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction);
context.summary.organizations.reused += 1;
return existingOrganizationId;
}
}
if (!normalizedRow.name) {
throw createBadRequestError('Organization name is required.');
}
const createdOrganization = await OrganizationsDBApi.create(normalizedRow, {
currentUser: context.currentUser,
transaction: context.transaction,
});
await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction);
context.summary.organizations.created += 1;
return createdOrganization.id;
};
const getOrCreatePropertyId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
let tenantId = null;
let organizationId = null;
if (normalizedRow.tenant) {
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction);
}
if (normalizedRow.organizations) {
organizationId = await resolveOrganizationReference(normalizedRow.organizations, {
tenantId,
transaction: context.transaction,
});
}
if (!tenantId && organizationId) {
tenantId = await resolveTenantIdForOrganization(organizationId, context.transaction);
}
if (context.currentUser?.app_role?.globalAccess && !organizationId) {
throw createBadRequestError('Organization is required for property import.');
}
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
if (reference) {
const existingPropertyId = await resolvePropertyReference(reference, {
currentUser: context.currentUser,
organizationId,
tenantId,
transaction: context.transaction,
});
if (existingPropertyId) {
context.summary.properties.reused += 1;
return existingPropertyId;
}
}
if (!normalizedRow.name) {
throw createBadRequestError('Property name is required.');
}
normalizedRow.tenant = tenantId || null;
normalizedRow.organizations = organizationId || null;
const createdProperty = await PropertiesDBApi.create(normalizedRow, {
currentUser: context.currentUser,
transaction: context.transaction,
});
context.summary.properties.created += 1;
return createdProperty.id;
};
const getOrCreateUnitTypeId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
const propertyId = await resolvePropertyReference(normalizedRow.property, {
currentUser: context.currentUser,
transaction: context.transaction,
});
if (!propertyId) {
throw createBadRequestError('Property is required for unit type import.');
}
const property = await loadPropertyContext(propertyId, context.transaction);
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
if (reference) {
const existingUnitTypeId = await resolveUnitTypeReference(reference, {
currentUser: context.currentUser,
organizationId: property?.organizationsId || null,
propertyId,
transaction: context.transaction,
});
if (existingUnitTypeId) {
context.summary.unit_types.reused += 1;
return existingUnitTypeId;
}
}
if (!normalizedRow.name) {
throw createBadRequestError('Unit type name is required.');
}
normalizedRow.property = propertyId;
normalizedRow.organizations = property?.organizationsId || null;
const createdUnitType = await UnitTypesDBApi.create(normalizedRow, {
currentUser: context.currentUser,
transaction: context.transaction,
});
context.summary.unit_types.created += 1;
return createdUnitType.id;
};
const resolveExistingUnit = async ({ id, unitNumber, propertyId, transaction }) => {
const filters = [];
if (id) {
filters.push({ id });
}
if (unitNumber) {
filters.push({
propertyId,
unit_number: {
[Op.iLike]: String(unitNumber),
},
});
}
if (!filters.length) {
return null;
}
const units = await db.units.findAll({
where: {
[Op.or]: filters,
},
transaction,
limit: 2,
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
});
if (!units.length) {
return null;
}
if (units.length > 1) {
throw createBadRequestError(`Unit "${unitNumber || id}" matches multiple records. Use the ID instead.`);
}
return units[0];
};
const getOrCreateUnitId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
const propertyId = await resolvePropertyReference(normalizedRow.property, {
currentUser: context.currentUser,
transaction: context.transaction,
});
if (!propertyId) {
throw createBadRequestError('Property is required for unit import.');
}
const property = await loadPropertyContext(propertyId, context.transaction);
const unitTypeId = await resolveUnitTypeReference(normalizedRow.unit_type, {
currentUser: context.currentUser,
organizationId: property?.organizationsId || null,
propertyId,
transaction: context.transaction,
});
if (!unitTypeId) {
throw createBadRequestError('Unit type is required for unit import.');
}
const existingUnit = await resolveExistingUnit({
id: normalizedRow.id || null,
unitNumber: normalizedRow.unit_number || null,
propertyId,
transaction: context.transaction,
});
if (existingUnit) {
context.summary.units.reused += 1;
return existingUnit.id;
}
if (!normalizedRow.unit_number) {
throw createBadRequestError('Unit number is required.');
}
normalizedRow.property = propertyId;
normalizedRow.unit_type = unitTypeId;
normalizedRow.organizations = property?.organizationsId || null;
const createdUnit = await UnitsDBApi.create(normalizedRow, {
currentUser: context.currentUser,
transaction: context.transaction,
});
context.summary.units.created += 1;
return createdUnit.id;
};
module.exports = class PortfolioWorkbookImportService {
static getSheetDefinitions() {
return SHEET_DEFINITIONS;
}
static getTemplateBuffer() {
return getWorkbookTemplateBuffer();
}
static async importWorkbook(req, res) {
await processFile(req, res);
validateWorkbookFile(req);
const workbook = XLSX.read(req.file.buffer, {
type: 'buffer',
raw: false,
});
const sheets = getWorkbookSheets(workbook);
ensureWorkbookHasRows(sheets);
ensurePermissionsForSheets(req.currentUser, sheets);
const transaction = await db.sequelize.transaction();
const summary = {
tenants: { created: 0, reused: 0 },
organizations: { created: 0, reused: 0 },
properties: { created: 0, reused: 0 },
unit_types: { created: 0, reused: 0 },
units: { created: 0, reused: 0 },
};
const context = {
currentUser: req.currentUser,
transaction,
summary,
};
try {
for (let index = 0; index < sheets.tenants.rows.length; index += 1) {
try {
await getOrCreateTenantId(sheets.tenants.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.tenants.label, index + 2);
}
}
for (let index = 0; index < sheets.organizations.rows.length; index += 1) {
try {
await getOrCreateOrganizationId(sheets.organizations.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.organizations.label, index + 2);
}
}
for (let index = 0; index < sheets.properties.rows.length; index += 1) {
try {
await getOrCreatePropertyId(sheets.properties.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.properties.label, index + 2);
}
}
for (let index = 0; index < sheets.unit_types.rows.length; index += 1) {
try {
await getOrCreateUnitTypeId(sheets.unit_types.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.unit_types.label, index + 2);
}
}
for (let index = 0; index < sheets.units.rows.length; index += 1) {
try {
await getOrCreateUnitId(sheets.units.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.units.label, index + 2);
}
}
await transaction.commit();
return {
workbook: {
fileName: req.file.originalname,
sheets: Object.values(sheets)
.filter((sheet) => sheet.name)
.map((sheet) => ({
key: sheet.definition.key,
name: sheet.name,
rows: sheet.rows.length,
})),
},
summary,
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ import {
} from '@mui/x-data-grid';
import {loadColumns} from "./configureOrganizationsCols";
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
import { hasPermission } from '../../helpers/userPermissions'
import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles";
@ -35,7 +36,6 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const [columns, setColumns] = useState<GridColDef[]>([]);
const [selectedRows, setSelectedRows] = useState([]);
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS');
const [sortModel, setSortModel] = useState([
{
field: '',
@ -45,6 +45,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const { organizations, loading, count, notify: organizationsNotify, refetch } = useAppSelector((state) => state.organizations)
const { currentUser } = useAppSelector((state) => state.auth);
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS');
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);

View File

@ -0,0 +1,103 @@
export type PortfolioWorkbookSheet = {
key: string;
label: string;
description: string;
columns: string[];
requiredColumns: string[];
exampleRow: Record<string, string>;
};
export const portfolioWorkbookSheets: PortfolioWorkbookSheet[] = [
{
key: 'tenants',
label: 'tenants',
description: 'Create or match tenant workspaces first. Tenants can be referenced later by ID, slug, or exact name.',
columns: ['id', 'name', 'slug', 'legal_name', 'primary_domain', 'timezone', 'default_currency', 'is_active', 'importHash'],
requiredColumns: ['name'],
exampleRow: {
id: '',
name: 'Corporate Stay Portal',
slug: 'corporate-stay-portal',
legal_name: 'Corporate Stay Portal LLC',
primary_domain: 'corpstay.example.com',
timezone: 'America/New_York',
default_currency: 'USD',
is_active: 'true',
importHash: 'tenant-csp-001',
},
},
{
key: 'organizations',
label: 'organizations',
description: 'Create customer organizations and link each row to a tenant from the tenants sheet or an already existing tenant.',
columns: ['id', 'tenant', 'name', 'importHash'],
requiredColumns: ['tenant', 'name'],
exampleRow: {
id: '',
tenant: 'corporate-stay-portal',
name: 'Acme Hospitality',
importHash: 'org-acme-001',
},
},
{
key: 'properties',
label: 'properties',
description: 'Create properties and link them to their tenant and organization. Organization can be referenced by ID or exact name.',
columns: ['id', 'tenant', 'organization', 'name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active', 'importHash'],
requiredColumns: ['organization', 'name'],
exampleRow: {
id: '',
tenant: 'corporate-stay-portal',
organization: 'Acme Hospitality',
name: 'Sunset Suites San Francisco',
code: 'SUNSET-SF',
address: '123 Market Street',
city: 'San Francisco',
country: 'USA',
timezone: 'America/Los_Angeles',
description: 'Primary Bay Area corporate stay property',
is_active: 'true',
importHash: 'property-sunset-sf-001',
},
},
{
key: 'unit_types',
label: 'unit_types',
description: 'Create unit types after properties. Property can be referenced by ID, property code, or exact property name.',
columns: ['id', 'property', 'name', 'code', 'description', 'max_occupancy', 'bedrooms', 'bathrooms', 'minimum_stay_nights', 'size_sqm', 'base_nightly_rate', 'base_monthly_rate', 'importHash'],
requiredColumns: ['property', 'name'],
exampleRow: {
id: '',
property: 'SUNSET-SF',
name: 'Studio Suite',
code: 'STD-STUDIO',
description: 'Furnished studio for short-term assignments',
max_occupancy: '2',
bedrooms: '1',
bathrooms: '1',
minimum_stay_nights: '30',
size_sqm: '42',
base_nightly_rate: '185',
base_monthly_rate: '4200',
importHash: 'unit-type-studio-001',
},
},
{
key: 'units',
label: 'units',
description: 'Create individual units after unit types. Unit type can be referenced by ID, code, or exact unit type name within the property.',
columns: ['id', 'property', 'unit_type', 'unit_number', 'floor', 'status', 'max_occupancy_override', 'notes', 'importHash'],
requiredColumns: ['property', 'unit_type', 'unit_number'],
exampleRow: {
id: '',
property: 'SUNSET-SF',
unit_type: 'STD-STUDIO',
unit_number: '1205',
floor: '12',
status: 'available',
max_occupancy_override: '2',
notes: 'Corner studio with city view',
importHash: 'unit-1205-001',
},
},
];

View File

@ -80,14 +80,38 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
label: 'Portfolio',
icon: icon.mdiHomeCity,
permissions: [
'READ_TENANTS',
'READ_ORGANIZATIONS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',
'READ_UNIT_TYPES',
'READ_AMENITIES',
'CREATE_TENANTS',
'CREATE_ORGANIZATIONS',
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
],
menu: [
{
href: '/portfolio-import',
label: 'Import Center',
icon: icon.mdiFileUploadOutline,
permissions: [
'CREATE_TENANTS',
'CREATE_ORGANIZATIONS',
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
],
},
{
href: '/tenants/tenants-list',
label: 'Tenants',
icon: icon.mdiDomain,
permissions: 'READ_TENANTS',
},
{
href: '/organizations/organizations-list',
label: 'Organizations',

View File

@ -0,0 +1,252 @@
import { mdiDownload, mdiFileUploadOutline, mdiHomeCity } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import DragDropFilePicker from '../components/DragDropFilePicker';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { portfolioWorkbookSheets } from '../helpers/portfolioWorkbookSheets';
import { hasPermission } from '../helpers/userPermissions';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type ImportSummary = Record<string, { created: number; reused: number }>;
type ImportResponse = {
workbook: {
fileName: string;
sheets: Array<{
key: string;
name: string;
rows: number;
}>;
};
summary: ImportSummary;
};
const createPermissions = [
'CREATE_TENANTS',
'CREATE_ORGANIZATIONS',
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
];
const summaryLabels: Record<string, string> = {
tenants: 'Tenants',
organizations: 'Organizations',
properties: 'Properties',
unit_types: 'Unit Types',
units: 'Units',
};
const PortfolioImportPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const canImportWorkbook = Boolean(currentUser?.app_role?.globalAccess || (currentUser && hasPermission(currentUser, createPermissions)));
const [workbookFile, setWorkbookFile] = useState<File | null>(null);
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
const workbookSummary = useMemo(() => {
if (!importResult?.summary) {
return [];
}
return Object.entries(importResult.summary).filter(([, value]) => value.created > 0 || value.reused > 0);
}, [importResult]);
const downloadTemplate = async () => {
try {
setIsDownloadingTemplate(true);
setErrorMessage('');
const response = await axios.get('/corporate-stay-portal/portfolio-import-template', {
responseType: 'blob',
});
const blob = new Blob([response.data], {
type:
response.headers['content-type'] ||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'portfolio-import-template.xlsx';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error: any) {
setErrorMessage(error?.response?.data || 'Failed to download the workbook template.');
} finally {
setIsDownloadingTemplate(false);
}
};
const submitWorkbook = async () => {
if (!workbookFile || !canImportWorkbook) {
return;
}
try {
setIsImporting(true);
setErrorMessage('');
setSuccessMessage('');
setImportResult(null);
const formData = new FormData();
formData.append('file', workbookFile);
formData.append('filename', workbookFile.name);
const response = await axios.post<ImportResponse>('/corporate-stay-portal/portfolio-import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setImportResult(response.data);
setSuccessMessage(`Workbook imported successfully: ${response.data.workbook.fileName}`);
setWorkbookFile(null);
} catch (error: any) {
setErrorMessage(error?.response?.data || 'Workbook import failed.');
} finally {
setIsImporting(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Portfolio Import Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiHomeCity} title='Portfolio Import Center' main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className='space-y-4'>
<div>
<h2 className='text-lg font-semibold text-gray-900 dark:text-slate-100'>One workbook for portfolio onboarding</h2>
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
Upload a single Excel workbook to create linked tenants, organizations, properties, unit types, and units in dependency order.
Existing records are reused when the workbook references a matching ID or business key.
</p>
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
This v1 workbook flow covers portfolio setup sheets only. Negotiated rates stay on their current flow for now.
</p>
</div>
{!canImportWorkbook && (
<div className='rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'>
You can review the template, but uploading requires at least one of these permissions: {createPermissions.join(', ')}.
</div>
)}
{errorMessage && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100'>
{errorMessage}
</div>
)}
{successMessage && (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100'>
{successMessage}
</div>
)}
<div className='flex flex-wrap gap-3'>
<BaseButton
color='info'
icon={mdiDownload}
label={isDownloadingTemplate ? 'Downloading...' : 'Download template'}
onClick={downloadTemplate}
disabled={isDownloadingTemplate}
/>
<BaseButton
color='info'
icon={mdiFileUploadOutline}
label={isImporting ? 'Importing workbook...' : 'Import workbook'}
onClick={submitWorkbook}
disabled={!workbookFile || !canImportWorkbook || isImporting}
/>
</div>
<DragDropFilePicker file={workbookFile} setFile={setWorkbookFile} formats={'.xlsx,.xls'} />
</div>
</CardBox>
{importResult && (
<CardBox className='mb-6'>
<div className='space-y-4'>
<div>
<h3 className='text-base font-semibold text-gray-900 dark:text-slate-100'>Latest import summary</h3>
<p className='mt-1 text-sm text-gray-600 dark:text-slate-300'>
Processed sheets:{' '}
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
</p>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
{workbookSummary.map(([key, value]) => (
<div key={key} className='rounded-xl border border-gray-200 px-4 py-3 dark:border-slate-700'>
<div className='text-sm font-semibold text-gray-900 dark:text-slate-100'>{summaryLabels[key] || key}</div>
<div className='mt-2 text-sm text-gray-600 dark:text-slate-300'>Created: {value.created}</div>
<div className='text-sm text-gray-600 dark:text-slate-300'>Reused: {value.reused}</div>
</div>
))}
</div>
</div>
</CardBox>
)}
<div className='grid gap-6 xl:grid-cols-2'>
{portfolioWorkbookSheets.map((sheet) => {
const exampleCsvRow = sheet.columns.map((column) => sheet.exampleRow[column] ?? '').join('\t');
return (
<CardBox key={sheet.key} className='h-full'>
<div className='space-y-4'>
<div>
<h3 className='text-base font-semibold uppercase tracking-wide text-gray-900 dark:text-slate-100'>{sheet.label}</h3>
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>{sheet.description}</p>
</div>
<div>
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>Required columns</div>
<p className='mt-1 text-sm text-gray-700 dark:text-slate-200'>{sheet.requiredColumns.join(', ')}</p>
</div>
<div>
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>All columns</div>
<p className='mt-1 break-words text-sm text-gray-700 dark:text-slate-200'>{sheet.columns.join(', ')}</p>
</div>
<div>
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>Example row</div>
<pre className='mt-2 overflow-x-auto rounded-xl bg-slate-900 px-4 py-3 text-xs text-slate-100'>
{`${sheet.columns.join('\t')}
${exampleCsvRow}`}
</pre>
</div>
</div>
</CardBox>
);
})}
</div>
</SectionMain>
</>
);
};
PortfolioImportPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default PortfolioImportPage;