Autosave: 20260404-190901
This commit is contained in:
parent
eaaae3e8f7
commit
beda38dd14
@ -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"
|
||||
|
||||
@ -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: [
|
||||
|
||||
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';
|
||||
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);
|
||||
|
||||
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',
|
||||
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',
|
||||
|
||||
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