Autosave: 20260404-190901
This commit is contained in:
parent
eaaae3e8f7
commit
beda38dd14
@ -38,7 +38,8 @@
|
|||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.2.4"
|
"tedious": "^18.2.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const { Op } = require('sequelize');
|
|||||||
|
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const PortfolioWorkbookImportService = require('../services/portfolioWorkbookImport');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -13,6 +14,8 @@ const servicePermissions = ['READ_SERVICE_REQUESTS'];
|
|||||||
const financePermissions = ['READ_INVOICES'];
|
const financePermissions = ['READ_INVOICES'];
|
||||||
const inventoryPermissions = ['READ_PROPERTIES', 'READ_UNITS'];
|
const inventoryPermissions = ['READ_PROPERTIES', 'READ_UNITS'];
|
||||||
const accountPermissions = ['READ_ORGANIZATIONS'];
|
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) {
|
function getPermissionSet(currentUser) {
|
||||||
return new Set([
|
return new Set([
|
||||||
@ -57,6 +60,38 @@ function formatUserDisplay(user) {
|
|||||||
return fullName || user.email || 'Assigned staff';
|
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) {
|
async function getGroupedCounts(model, statusField, where) {
|
||||||
const rows = await model.findAll({
|
const rows = await model.findAll({
|
||||||
attributes: [
|
attributes: [
|
||||||
|
|||||||
713
backend/src/services/portfolioWorkbookImport.js
Normal file
713
backend/src/services/portfolioWorkbookImport.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
1387
backend/yarn.lock
1387
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ import {
|
|||||||
} from '@mui/x-data-grid';
|
} from '@mui/x-data-grid';
|
||||||
import {loadColumns} from "./configureOrganizationsCols";
|
import {loadColumns} from "./configureOrganizationsCols";
|
||||||
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
|
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
|
||||||
|
import { hasPermission } from '../../helpers/userPermissions'
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import dataFormatter from '../../helpers/dataFormatter'
|
import dataFormatter from '../../helpers/dataFormatter'
|
||||||
import {dataGridStyles} from "../../styles";
|
import {dataGridStyles} from "../../styles";
|
||||||
@ -35,7 +36,6 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||||
const [selectedRows, setSelectedRows] = useState([]);
|
const [selectedRows, setSelectedRows] = useState([]);
|
||||||
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
|
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
|
||||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS');
|
|
||||||
const [sortModel, setSortModel] = useState([
|
const [sortModel, setSortModel] = useState([
|
||||||
{
|
{
|
||||||
field: '',
|
field: '',
|
||||||
@ -45,6 +45,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
|
|
||||||
const { organizations, loading, count, notify: organizationsNotify, refetch } = useAppSelector((state) => state.organizations)
|
const { organizations, loading, count, notify: organizationsNotify, refetch } = useAppSelector((state) => state.organizations)
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS');
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
|
|||||||
103
frontend/src/helpers/portfolioWorkbookSheets.ts
Normal file
103
frontend/src/helpers/portfolioWorkbookSheets.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -80,14 +80,38 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
|
|||||||
label: 'Portfolio',
|
label: 'Portfolio',
|
||||||
icon: icon.mdiHomeCity,
|
icon: icon.mdiHomeCity,
|
||||||
permissions: [
|
permissions: [
|
||||||
|
'READ_TENANTS',
|
||||||
'READ_ORGANIZATIONS',
|
'READ_ORGANIZATIONS',
|
||||||
'READ_PROPERTIES',
|
'READ_PROPERTIES',
|
||||||
'READ_UNITS',
|
'READ_UNITS',
|
||||||
'READ_NEGOTIATED_RATES',
|
'READ_NEGOTIATED_RATES',
|
||||||
'READ_UNIT_TYPES',
|
'READ_UNIT_TYPES',
|
||||||
'READ_AMENITIES',
|
'READ_AMENITIES',
|
||||||
|
'CREATE_TENANTS',
|
||||||
|
'CREATE_ORGANIZATIONS',
|
||||||
|
'CREATE_PROPERTIES',
|
||||||
|
'CREATE_UNIT_TYPES',
|
||||||
|
'CREATE_UNITS',
|
||||||
],
|
],
|
||||||
menu: [
|
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',
|
href: '/organizations/organizations-list',
|
||||||
label: 'Organizations',
|
label: 'Organizations',
|
||||||
|
|||||||
252
frontend/src/pages/portfolio-import.tsx
Normal file
252
frontend/src/pages/portfolio-import.tsx
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user