Autosave: 20260404-200433

This commit is contained in:
Flatlogic Bot 2026-04-04 20:04:33 +00:00
parent beda38dd14
commit 8514d4078a
4 changed files with 599 additions and 33 deletions

View File

@ -106,6 +106,27 @@ const SHEET_DEFINITIONS = [
], ],
requiredHeaders: ['property', 'unit_type', 'unit_number'], requiredHeaders: ['property', 'unit_type', 'unit_number'],
}, },
{
key: 'negotiated_rates',
label: 'Negotiated Rates',
aliases: ['negotiated_rates', 'negotiated rates', 'negotiated-rates', 'negotiated rate'],
allowedHeaders: [
'id',
'tenant',
'organization',
'property',
'unit_type',
'rate_type',
'rate_amount',
'valid_from',
'valid_until',
'minimum_stay_nights',
'is_active',
'notes',
'importHash',
],
requiredHeaders: ['property', 'rate_type', 'rate_amount'],
},
]; ];
const SHEET_DEFINITION_BY_KEY = SHEET_DEFINITIONS.reduce((accumulator, definition) => { const SHEET_DEFINITION_BY_KEY = SHEET_DEFINITIONS.reduce((accumulator, definition) => {
@ -142,6 +163,67 @@ const normalizeRowValue = (value) => {
return value; return value;
}; };
const normalizeNegotiatedRateType = (value) => {
if (isBlankValue(value)) {
return null;
}
const rawValue = String(value).trim().toLowerCase();
const normalizedValue = rawValue.replace(/[^a-z0-9]+/g, ' ').trim();
if (['nightly', 'night', 'nightly rate', 'per night', 'per nightly'].includes(normalizedValue)) {
return 'nightly';
}
if (['monthly', 'month', 'monthly rate', 'per month', 'per monthly'].includes(normalizedValue)) {
return 'monthly';
}
return rawValue;
};
const parseOptionalInteger = (value, label) => {
if (isBlankValue(value)) {
return null;
}
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw createBadRequestError(`${label} must be a whole number.`);
}
return parsed;
};
const parseRequiredDecimal = (value, label) => {
if (isBlankValue(value)) {
throw createBadRequestError(`${label} is required.`);
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw createBadRequestError(`${label} must be a valid number.`);
}
return parsed;
};
const parseOptionalDate = (value, label) => {
if (isBlankValue(value)) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw createBadRequestError(`${label} must be a valid date.`);
}
return parsed;
};
const prefixSheetImportError = (error, sheetLabel, rowNumber) => { const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
if (!error) { if (!error) {
return error; return error;
@ -156,6 +238,123 @@ const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
return error; return error;
}; };
const resolveExistingReferenceOrNull = async (resolver, { allowMessages = [] } = {}) => {
try {
return await resolver();
} catch (error) {
const message = error?.message || '';
const isAllowedMessage = allowMessages.some((pattern) =>
pattern instanceof RegExp ? pattern.test(message) : pattern === message,
);
if (error?.code === 400 && (/ was not found\.$/.test(message) || isAllowedMessage)) {
return null;
}
throw error;
}
};
const normalizeWorkbookReference = (value) => {
if (isBlankValue(value)) {
return null;
}
return String(value).trim().toLowerCase();
};
const createWorkbookReferenceRegistry = () => ({
tenants: [],
organizations: [],
properties: [],
unit_types: [],
});
const getWorkbookReferenceRegistry = (context) => {
if (!context.workbookReferences) {
context.workbookReferences = createWorkbookReferenceRegistry();
}
return context.workbookReferences;
};
const registerWorkbookReference = (
context,
entityKey,
{ id, references = [], tenantId = null, organizationId = null, propertyId = null } = {},
) => {
if (!entityKey || !id) {
return;
}
const registry = getWorkbookReferenceRegistry(context);
if (!registry[entityKey]) {
registry[entityKey] = [];
}
registry[entityKey].push({
id,
references: Array.from(new Set(references.map((value) => normalizeWorkbookReference(value)).filter(Boolean))),
tenantId: tenantId || null,
organizationId: organizationId || null,
propertyId: propertyId || null,
});
};
const resolveWorkbookReference = (
context,
entityKey,
label,
reference,
{ tenantId = null, organizationId = null, propertyId = null } = {},
) => {
const normalizedReference = normalizeWorkbookReference(reference);
if (!normalizedReference) {
return null;
}
const registry = getWorkbookReferenceRegistry(context);
const uniqueMatchIds = Array.from(
new Set(
(registry[entityKey] || [])
.filter((entry) => entry.references.includes(normalizedReference))
.filter((entry) => !tenantId || entry.tenantId === tenantId)
.filter((entry) => !organizationId || entry.organizationId === organizationId)
.filter((entry) => !propertyId || entry.propertyId === propertyId)
.map((entry) => entry.id),
),
);
if (!uniqueMatchIds.length) {
return null;
}
if (uniqueMatchIds.length > 1) {
throw createBadRequestError(`${label} "${reference}" matches multiple workbook rows. Use the ID instead.`);
}
return uniqueMatchIds[0];
};
const resolveWorkbookReferenceOrDb = async ({
context,
entityKey,
label,
reference,
scope = {},
resolver,
}) => {
const workbookMatchId = resolveWorkbookReference(context, entityKey, label, reference, scope);
if (workbookMatchId) {
return workbookMatchId;
}
return resolver();
};
const validateWorkbookFile = (req) => { const validateWorkbookFile = (req) => {
if (!req.file || !req.file.buffer || !req.file.buffer.length) { if (!req.file || !req.file.buffer || !req.file.buffer.length) {
throw createBadRequestError('Workbook upload is empty.'); throw createBadRequestError('Workbook upload is empty.');
@ -319,6 +518,10 @@ const ensurePermissionsForSheets = (currentUser, sheets) => {
requiredPermissions.push('CREATE_UNITS'); requiredPermissions.push('CREATE_UNITS');
} }
if (sheets.negotiated_rates.rows.length) {
requiredPermissions.push('CREATE_NEGOTIATED_RATES');
}
const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission)); const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission));
if (missingPermissions.length) { if (missingPermissions.length) {
@ -335,8 +538,20 @@ const getOrCreateTenantId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name; const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name;
if (reference) { if (reference) {
const existingTenantId = await resolveTenantReference(reference, context.transaction); const existingTenantId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'tenants',
label: 'Tenant',
reference,
resolver: () =>
resolveExistingReferenceOrNull(() => resolveTenantReference(reference, context.transaction)),
});
if (existingTenantId) { if (existingTenantId) {
registerWorkbookReference(context, 'tenants', {
id: existingTenantId,
references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
});
context.summary.tenants.reused += 1; context.summary.tenants.reused += 1;
return existingTenantId; return existingTenantId;
} }
@ -351,6 +566,11 @@ const getOrCreateTenantId = async (row, context) => {
transaction: context.transaction, transaction: context.transaction,
}); });
registerWorkbookReference(context, 'tenants', {
id: createdTenant.id,
references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
});
context.summary.tenants.created += 1; context.summary.tenants.created += 1;
return createdTenant.id; return createdTenant.id;
}; };
@ -383,7 +603,14 @@ const getOrCreateOrganizationId = async (row, context) => {
let tenantId = null; let tenantId = null;
if (normalizedRow.tenant) { if (normalizedRow.tenant) {
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction); tenantId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'tenants',
label: 'Tenant',
reference: normalizedRow.tenant,
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
});
if (!tenantId) { if (!tenantId) {
throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`); throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`);
} }
@ -392,13 +619,32 @@ const getOrCreateOrganizationId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.name; const reference = normalizedRow.id || normalizedRow.name;
if (reference) { if (reference) {
const existingOrganizationId = await resolveOrganizationReference(reference, { const existingOrganizationId = await resolveWorkbookReferenceOrDb({
tenantId, context,
transaction: context.transaction, entityKey: 'organizations',
label: 'Organization',
reference,
scope: { tenantId },
resolver: () =>
resolveExistingReferenceOrNull(
() =>
resolveOrganizationReference(reference, {
tenantId,
transaction: context.transaction,
}),
{
allowMessages: ['The selected tenant has no organizations available for import.'],
},
),
}); });
if (existingOrganizationId) { if (existingOrganizationId) {
await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction); await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction);
registerWorkbookReference(context, 'organizations', {
id: existingOrganizationId,
references: [normalizedRow.id, normalizedRow.name],
tenantId,
});
context.summary.organizations.reused += 1; context.summary.organizations.reused += 1;
return existingOrganizationId; return existingOrganizationId;
} }
@ -415,6 +661,12 @@ const getOrCreateOrganizationId = async (row, context) => {
await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction); await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction);
registerWorkbookReference(context, 'organizations', {
id: createdOrganization.id,
references: [normalizedRow.id, normalizedRow.name],
tenantId,
});
context.summary.organizations.created += 1; context.summary.organizations.created += 1;
return createdOrganization.id; return createdOrganization.id;
}; };
@ -425,13 +677,27 @@ const getOrCreatePropertyId = async (row, context) => {
let organizationId = null; let organizationId = null;
if (normalizedRow.tenant) { if (normalizedRow.tenant) {
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction); tenantId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'tenants',
label: 'Tenant',
reference: normalizedRow.tenant,
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
});
} }
if (normalizedRow.organizations) { if (normalizedRow.organizations) {
organizationId = await resolveOrganizationReference(normalizedRow.organizations, { organizationId = await resolveWorkbookReferenceOrDb({
tenantId, context,
transaction: context.transaction, entityKey: 'organizations',
label: 'Organization',
reference: normalizedRow.organizations,
scope: { tenantId },
resolver: () =>
resolveOrganizationReference(normalizedRow.organizations, {
tenantId,
transaction: context.transaction,
}),
}); });
} }
@ -446,14 +712,30 @@ const getOrCreatePropertyId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name; const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
if (reference) { if (reference) {
const existingPropertyId = await resolvePropertyReference(reference, { const existingPropertyId = await resolveWorkbookReferenceOrDb({
currentUser: context.currentUser, context,
organizationId, entityKey: 'properties',
tenantId, label: 'Property',
transaction: context.transaction, reference,
scope: { tenantId, organizationId },
resolver: () =>
resolveExistingReferenceOrNull(() =>
resolvePropertyReference(reference, {
currentUser: context.currentUser,
organizationId,
tenantId,
transaction: context.transaction,
}),
),
}); });
if (existingPropertyId) { if (existingPropertyId) {
registerWorkbookReference(context, 'properties', {
id: existingPropertyId,
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
tenantId,
organizationId,
});
context.summary.properties.reused += 1; context.summary.properties.reused += 1;
return existingPropertyId; return existingPropertyId;
} }
@ -471,15 +753,29 @@ const getOrCreatePropertyId = async (row, context) => {
transaction: context.transaction, transaction: context.transaction,
}); });
registerWorkbookReference(context, 'properties', {
id: createdProperty.id,
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
tenantId,
organizationId,
});
context.summary.properties.created += 1; context.summary.properties.created += 1;
return createdProperty.id; return createdProperty.id;
}; };
const getOrCreateUnitTypeId = async (row, context) => { const getOrCreateUnitTypeId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row); const normalizedRow = normalizeInventoryImportRow(row);
const propertyId = await resolvePropertyReference(normalizedRow.property, { const propertyId = await resolveWorkbookReferenceOrDb({
currentUser: context.currentUser, context,
transaction: context.transaction, entityKey: 'properties',
label: 'Property',
reference: normalizedRow.property,
resolver: () =>
resolvePropertyReference(normalizedRow.property, {
currentUser: context.currentUser,
transaction: context.transaction,
}),
}); });
if (!propertyId) { if (!propertyId) {
@ -490,14 +786,33 @@ const getOrCreateUnitTypeId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name; const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
if (reference) { if (reference) {
const existingUnitTypeId = await resolveUnitTypeReference(reference, { const existingUnitTypeId = await resolveWorkbookReferenceOrDb({
currentUser: context.currentUser, context,
organizationId: property?.organizationsId || null, entityKey: 'unit_types',
propertyId, label: 'Unit type',
transaction: context.transaction, reference,
scope: {
organizationId: property?.organizationsId || null,
propertyId,
},
resolver: () =>
resolveExistingReferenceOrNull(() =>
resolveUnitTypeReference(reference, {
currentUser: context.currentUser,
organizationId: property?.organizationsId || null,
propertyId,
transaction: context.transaction,
}),
),
}); });
if (existingUnitTypeId) { if (existingUnitTypeId) {
registerWorkbookReference(context, 'unit_types', {
id: existingUnitTypeId,
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
organizationId: property?.organizationsId || null,
propertyId,
});
context.summary.unit_types.reused += 1; context.summary.unit_types.reused += 1;
return existingUnitTypeId; return existingUnitTypeId;
} }
@ -515,6 +830,13 @@ const getOrCreateUnitTypeId = async (row, context) => {
transaction: context.transaction, transaction: context.transaction,
}); });
registerWorkbookReference(context, 'unit_types', {
id: createdUnitType.id,
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
organizationId: property?.organizationsId || null,
propertyId,
});
context.summary.unit_types.created += 1; context.summary.unit_types.created += 1;
return createdUnitType.id; return createdUnitType.id;
}; };
@ -559,11 +881,209 @@ const resolveExistingUnit = async ({ id, unitNumber, propertyId, transaction })
return units[0]; return units[0];
}; };
const resolveExistingNegotiatedRate = async ({
id,
importHash,
propertyId,
unitTypeId,
rateType,
rateAmount,
validFrom,
validUntil,
transaction,
}) => {
const filters = [];
if (id) {
filters.push({ id });
}
if (importHash) {
filters.push({ importHash });
}
if (propertyId && rateType) {
filters.push({
propertyId,
unit_typeId: unitTypeId || null,
rate_type: rateType,
rate_amount: rateAmount,
valid_from: validFrom || null,
valid_until: validUntil || null,
});
}
if (!filters.length) {
return null;
}
const negotiatedRates = await db.negotiated_rates.findAll({
where: {
[Op.or]: filters,
},
transaction,
limit: 2,
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
});
if (!negotiatedRates.length) {
return null;
}
if (negotiatedRates.length > 1) {
throw createBadRequestError('Negotiated rate matches multiple records. Use the ID or importHash instead.');
}
return negotiatedRates[0];
};
const getOrCreateNegotiatedRateId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
let tenantId = null;
let organizationId = null;
if (normalizedRow.tenant) {
tenantId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'tenants',
label: 'Tenant',
reference: normalizedRow.tenant,
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
});
}
if (normalizedRow.organizations) {
organizationId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'organizations',
label: 'Organization',
reference: normalizedRow.organizations,
scope: { tenantId },
resolver: () =>
resolveOrganizationReference(normalizedRow.organizations, {
tenantId,
transaction: context.transaction,
}),
});
}
const propertyId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'properties',
label: 'Property',
reference: normalizedRow.property,
scope: { tenantId, organizationId },
resolver: () =>
resolvePropertyReference(normalizedRow.property, {
currentUser: context.currentUser,
organizationId,
tenantId,
transaction: context.transaction,
}),
});
if (!propertyId) {
throw createBadRequestError('Property is required for negotiated rate import.');
}
const property = await loadPropertyContext(propertyId, context.transaction);
const propertyOrganizationId = property?.organizationsId || null;
if (organizationId && propertyOrganizationId && organizationId !== propertyOrganizationId) {
throw createBadRequestError('Property does not belong to the provided organization.');
}
organizationId = propertyOrganizationId || organizationId || null;
let unitTypeId = null;
if (normalizedRow.unit_type) {
unitTypeId = await resolveWorkbookReferenceOrDb({
context,
entityKey: 'unit_types',
label: 'Unit type',
reference: normalizedRow.unit_type,
scope: { organizationId, propertyId },
resolver: () =>
resolveUnitTypeReference(normalizedRow.unit_type, {
currentUser: context.currentUser,
organizationId,
propertyId,
transaction: context.transaction,
}),
});
}
const rateType = normalizeNegotiatedRateType(normalizedRow.rate_type);
if (!['nightly', 'monthly'].includes(rateType)) {
const providedRateType = isBlankValue(normalizedRow.rate_type)
? 'blank'
: String(normalizedRow.rate_type).trim();
throw createBadRequestError(`Rate type "${providedRateType}" is invalid. Use nightly or monthly.`);
}
const rateAmount = parseRequiredDecimal(normalizedRow.rate_amount, 'Rate amount');
const validFrom = parseOptionalDate(normalizedRow.valid_from, 'Valid from');
const validUntil = parseOptionalDate(normalizedRow.valid_until, 'Valid until');
const minimumStayNights = parseOptionalInteger(normalizedRow.minimum_stay_nights, 'Minimum stay nights');
if (validFrom && validUntil && validUntil.getTime() < validFrom.getTime()) {
throw createBadRequestError('Valid until must be on or after valid from.');
}
const existingNegotiatedRate = await resolveExistingNegotiatedRate({
id: normalizedRow.id || null,
importHash: normalizedRow.importHash || null,
propertyId,
unitTypeId,
rateType,
rateAmount,
validFrom,
validUntil,
transaction: context.transaction,
});
if (existingNegotiatedRate) {
context.summary.negotiated_rates.reused += 1;
return existingNegotiatedRate.id;
}
const createdNegotiatedRate = await db.negotiated_rates.create(
{
id: normalizedRow.id || undefined,
rate_type: rateType,
rate_amount: rateAmount,
valid_from: validFrom,
valid_until: validUntil,
minimum_stay_nights: minimumStayNights,
is_active: typeof normalizedRow.is_active === 'boolean' ? normalizedRow.is_active : false,
notes: isBlankValue(normalizedRow.notes) ? null : normalizedRow.notes,
importHash: normalizedRow.importHash || null,
organizationId,
propertyId,
unit_typeId: unitTypeId,
createdById: context.currentUser?.id || null,
updatedById: context.currentUser?.id || null,
},
{ transaction: context.transaction },
);
context.summary.negotiated_rates.created += 1;
return createdNegotiatedRate.id;
};
const getOrCreateUnitId = async (row, context) => { const getOrCreateUnitId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row); const normalizedRow = normalizeInventoryImportRow(row);
const propertyId = await resolvePropertyReference(normalizedRow.property, { const propertyId = await resolveWorkbookReferenceOrDb({
currentUser: context.currentUser, context,
transaction: context.transaction, entityKey: 'properties',
label: 'Property',
reference: normalizedRow.property,
resolver: () =>
resolvePropertyReference(normalizedRow.property, {
currentUser: context.currentUser,
transaction: context.transaction,
}),
}); });
if (!propertyId) { if (!propertyId) {
@ -571,11 +1091,22 @@ const getOrCreateUnitId = async (row, context) => {
} }
const property = await loadPropertyContext(propertyId, context.transaction); const property = await loadPropertyContext(propertyId, context.transaction);
const unitTypeId = await resolveUnitTypeReference(normalizedRow.unit_type, { const unitTypeId = await resolveWorkbookReferenceOrDb({
currentUser: context.currentUser, context,
organizationId: property?.organizationsId || null, entityKey: 'unit_types',
propertyId, label: 'Unit type',
transaction: context.transaction, reference: normalizedRow.unit_type,
scope: {
organizationId: property?.organizationsId || null,
propertyId,
},
resolver: () =>
resolveUnitTypeReference(normalizedRow.unit_type, {
currentUser: context.currentUser,
organizationId: property?.organizationsId || null,
propertyId,
transaction: context.transaction,
}),
}); });
if (!unitTypeId) { if (!unitTypeId) {
@ -641,6 +1172,7 @@ module.exports = class PortfolioWorkbookImportService {
properties: { created: 0, reused: 0 }, properties: { created: 0, reused: 0 },
unit_types: { created: 0, reused: 0 }, unit_types: { created: 0, reused: 0 },
units: { created: 0, reused: 0 }, units: { created: 0, reused: 0 },
negotiated_rates: { created: 0, reused: 0 },
}; };
const context = { const context = {
@ -690,6 +1222,14 @@ module.exports = class PortfolioWorkbookImportService {
} }
} }
for (let index = 0; index < sheets.negotiated_rates.rows.length; index += 1) {
try {
await getOrCreateNegotiatedRateId(sheets.negotiated_rates.rows[index], context);
} catch (error) {
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.negotiated_rates.label, index + 2);
}
}
await transaction.commit(); await transaction.commit();
return { return {

View File

@ -100,4 +100,26 @@ export const portfolioWorkbookSheets: PortfolioWorkbookSheet[] = [
importHash: 'unit-1205-001', importHash: 'unit-1205-001',
}, },
}, },
{
key: 'negotiated_rates',
label: 'negotiated_rates',
description: 'Create negotiated rates after properties and unit types. Property is required; unit_type is optional when the rate applies to the full property.',
columns: ['id', 'tenant', 'organization', 'property', 'unit_type', 'rate_type', 'rate_amount', 'valid_from', 'valid_until', 'minimum_stay_nights', 'is_active', 'notes', 'importHash'],
requiredColumns: ['property', 'rate_type', 'rate_amount'],
exampleRow: {
id: '',
tenant: 'corporate-stay-portal',
organization: 'Acme Hospitality',
property: 'SUNSET-SF',
unit_type: 'STD-STUDIO',
rate_type: 'monthly',
rate_amount: '3990',
valid_from: '2026-05-01',
valid_until: '2026-12-31',
minimum_stay_nights: '30',
is_active: 'true',
notes: 'Preferred client housing program rate',
importHash: 'neg-rate-acme-studio-2026',
},
},
]; ];

View File

@ -92,6 +92,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
'CREATE_PROPERTIES', 'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES', 'CREATE_UNIT_TYPES',
'CREATE_UNITS', 'CREATE_UNITS',
'CREATE_NEGOTIATED_RATES',
], ],
menu: [ menu: [
{ {
@ -104,6 +105,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
'CREATE_PROPERTIES', 'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES', 'CREATE_UNIT_TYPES',
'CREATE_UNITS', 'CREATE_UNITS',
'CREATE_NEGOTIATED_RATES',
], ],
}, },
{ {

View File

@ -34,6 +34,7 @@ const createPermissions = [
'CREATE_PROPERTIES', 'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES', 'CREATE_UNIT_TYPES',
'CREATE_UNITS', 'CREATE_UNITS',
'CREATE_NEGOTIATED_RATES',
]; ];
const summaryLabels: Record<string, string> = { const summaryLabels: Record<string, string> = {
@ -42,6 +43,7 @@ const summaryLabels: Record<string, string> = {
properties: 'Properties', properties: 'Properties',
unit_types: 'Unit Types', unit_types: 'Unit Types',
units: 'Units', units: 'Units',
negotiated_rates: 'Negotiated Rates',
}; };
const PortfolioImportPage = () => { const PortfolioImportPage = () => {
@ -136,11 +138,11 @@ const PortfolioImportPage = () => {
<div> <div>
<h2 className='text-lg font-semibold text-gray-900 dark:text-slate-100'>One workbook for portfolio onboarding</h2> <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'> <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. Upload a single Excel workbook to create linked tenants, organizations, properties, unit types, units, and negotiated rates in dependency order.
Existing records are reused when the workbook references a matching ID or business key. Existing records are reused when the workbook references a matching ID or business key.
</p> </p>
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'> <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. Negotiated rates are linked after units and can reference a property plus an optional unit type.
</p> </p>
</div> </div>
@ -193,7 +195,7 @@ const PortfolioImportPage = () => {
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')} {importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
</p> </p>
</div> </div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-6'>
{workbookSummary.map(([key, value]) => ( {workbookSummary.map(([key, value]) => (
<div key={key} className='rounded-xl border border-gray-200 px-4 py-3 dark:border-slate-700'> <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='text-sm font-semibold text-gray-900 dark:text-slate-100'>{summaryLabels[key] || key}</div>