From 8514d4078a1769971961bddb05e4439ee2315250 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 4 Apr 2026 20:04:33 +0000 Subject: [PATCH] Autosave: 20260404-200433 --- .../src/services/portfolioWorkbookImport.js | 600 +++++++++++++++++- .../src/helpers/portfolioWorkbookSheets.ts | 22 + frontend/src/menuAside.ts | 2 + frontend/src/pages/portfolio-import.tsx | 8 +- 4 files changed, 599 insertions(+), 33 deletions(-) diff --git a/backend/src/services/portfolioWorkbookImport.js b/backend/src/services/portfolioWorkbookImport.js index bd8131f..1378697 100644 --- a/backend/src/services/portfolioWorkbookImport.js +++ b/backend/src/services/portfolioWorkbookImport.js @@ -106,6 +106,27 @@ const SHEET_DEFINITIONS = [ ], 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) => { @@ -142,6 +163,67 @@ const normalizeRowValue = (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) => { if (!error) { return error; @@ -156,6 +238,123 @@ const prefixSheetImportError = (error, sheetLabel, rowNumber) => { 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) => { if (!req.file || !req.file.buffer || !req.file.buffer.length) { throw createBadRequestError('Workbook upload is empty.'); @@ -319,6 +518,10 @@ const ensurePermissionsForSheets = (currentUser, sheets) => { requiredPermissions.push('CREATE_UNITS'); } + if (sheets.negotiated_rates.rows.length) { + requiredPermissions.push('CREATE_NEGOTIATED_RATES'); + } + const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission)); if (missingPermissions.length) { @@ -335,8 +538,20 @@ const getOrCreateTenantId = async (row, context) => { const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name; 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) { + registerWorkbookReference(context, 'tenants', { + id: existingTenantId, + references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name], + }); context.summary.tenants.reused += 1; return existingTenantId; } @@ -351,6 +566,11 @@ const getOrCreateTenantId = async (row, context) => { transaction: context.transaction, }); + registerWorkbookReference(context, 'tenants', { + id: createdTenant.id, + references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name], + }); + context.summary.tenants.created += 1; return createdTenant.id; }; @@ -383,7 +603,14 @@ const getOrCreateOrganizationId = async (row, context) => { let tenantId = null; 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) { throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`); } @@ -392,13 +619,32 @@ const getOrCreateOrganizationId = async (row, context) => { const reference = normalizedRow.id || normalizedRow.name; if (reference) { - const existingOrganizationId = await resolveOrganizationReference(reference, { - tenantId, - transaction: context.transaction, + const existingOrganizationId = await resolveWorkbookReferenceOrDb({ + context, + 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) { await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction); + registerWorkbookReference(context, 'organizations', { + id: existingOrganizationId, + references: [normalizedRow.id, normalizedRow.name], + tenantId, + }); context.summary.organizations.reused += 1; return existingOrganizationId; } @@ -415,6 +661,12 @@ const getOrCreateOrganizationId = async (row, context) => { await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction); + registerWorkbookReference(context, 'organizations', { + id: createdOrganization.id, + references: [normalizedRow.id, normalizedRow.name], + tenantId, + }); + context.summary.organizations.created += 1; return createdOrganization.id; }; @@ -425,13 +677,27 @@ const getOrCreatePropertyId = async (row, context) => { let organizationId = null; 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) { - organizationId = await resolveOrganizationReference(normalizedRow.organizations, { - tenantId, - transaction: context.transaction, + organizationId = await resolveWorkbookReferenceOrDb({ + context, + 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; if (reference) { - const existingPropertyId = await resolvePropertyReference(reference, { - currentUser: context.currentUser, - organizationId, - tenantId, - transaction: context.transaction, + const existingPropertyId = await resolveWorkbookReferenceOrDb({ + context, + entityKey: 'properties', + label: 'Property', + reference, + scope: { tenantId, organizationId }, + resolver: () => + resolveExistingReferenceOrNull(() => + resolvePropertyReference(reference, { + currentUser: context.currentUser, + organizationId, + tenantId, + transaction: context.transaction, + }), + ), }); if (existingPropertyId) { + registerWorkbookReference(context, 'properties', { + id: existingPropertyId, + references: [normalizedRow.id, normalizedRow.code, normalizedRow.name], + tenantId, + organizationId, + }); context.summary.properties.reused += 1; return existingPropertyId; } @@ -471,15 +753,29 @@ const getOrCreatePropertyId = async (row, context) => { transaction: context.transaction, }); + registerWorkbookReference(context, 'properties', { + id: createdProperty.id, + references: [normalizedRow.id, normalizedRow.code, normalizedRow.name], + tenantId, + organizationId, + }); + 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, + const propertyId = await resolveWorkbookReferenceOrDb({ + context, + entityKey: 'properties', + label: 'Property', + reference: normalizedRow.property, + resolver: () => + resolvePropertyReference(normalizedRow.property, { + currentUser: context.currentUser, + transaction: context.transaction, + }), }); if (!propertyId) { @@ -490,14 +786,33 @@ const getOrCreateUnitTypeId = async (row, context) => { 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, + const existingUnitTypeId = await resolveWorkbookReferenceOrDb({ + context, + entityKey: 'unit_types', + label: 'Unit type', + reference, + scope: { + organizationId: property?.organizationsId || null, + propertyId, + }, + resolver: () => + resolveExistingReferenceOrNull(() => + resolveUnitTypeReference(reference, { + currentUser: context.currentUser, + organizationId: property?.organizationsId || null, + propertyId, + transaction: context.transaction, + }), + ), }); 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; return existingUnitTypeId; } @@ -515,6 +830,13 @@ const getOrCreateUnitTypeId = async (row, context) => { 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; return createdUnitType.id; }; @@ -559,11 +881,209 @@ const resolveExistingUnit = async ({ id, unitNumber, propertyId, transaction }) 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 normalizedRow = normalizeInventoryImportRow(row); - const propertyId = await resolvePropertyReference(normalizedRow.property, { - currentUser: context.currentUser, - transaction: context.transaction, + const propertyId = await resolveWorkbookReferenceOrDb({ + context, + entityKey: 'properties', + label: 'Property', + reference: normalizedRow.property, + resolver: () => + resolvePropertyReference(normalizedRow.property, { + currentUser: context.currentUser, + transaction: context.transaction, + }), }); if (!propertyId) { @@ -571,11 +1091,22 @@ const getOrCreateUnitId = async (row, context) => { } 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, + const unitTypeId = await resolveWorkbookReferenceOrDb({ + context, + entityKey: 'unit_types', + label: 'Unit type', + 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) { @@ -641,6 +1172,7 @@ module.exports = class PortfolioWorkbookImportService { properties: { created: 0, reused: 0 }, unit_types: { created: 0, reused: 0 }, units: { created: 0, reused: 0 }, + negotiated_rates: { created: 0, reused: 0 }, }; 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(); return { diff --git a/frontend/src/helpers/portfolioWorkbookSheets.ts b/frontend/src/helpers/portfolioWorkbookSheets.ts index 2e5050e..fdacf0a 100644 --- a/frontend/src/helpers/portfolioWorkbookSheets.ts +++ b/frontend/src/helpers/portfolioWorkbookSheets.ts @@ -100,4 +100,26 @@ export const portfolioWorkbookSheets: PortfolioWorkbookSheet[] = [ 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', + }, + }, ]; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6199888..7ba8b92 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -92,6 +92,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => { 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS', + 'CREATE_NEGOTIATED_RATES', ], menu: [ { @@ -104,6 +105,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => { 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS', + 'CREATE_NEGOTIATED_RATES', ], }, { diff --git a/frontend/src/pages/portfolio-import.tsx b/frontend/src/pages/portfolio-import.tsx index d6d299e..90a6bff 100644 --- a/frontend/src/pages/portfolio-import.tsx +++ b/frontend/src/pages/portfolio-import.tsx @@ -34,6 +34,7 @@ const createPermissions = [ 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS', + 'CREATE_NEGOTIATED_RATES', ]; const summaryLabels: Record = { @@ -42,6 +43,7 @@ const summaryLabels: Record = { properties: 'Properties', unit_types: 'Unit Types', units: 'Units', + negotiated_rates: 'Negotiated Rates', }; const PortfolioImportPage = () => { @@ -136,11 +138,11 @@ const PortfolioImportPage = () => {

One workbook for portfolio onboarding

- 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.

- 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.

@@ -193,7 +195,7 @@ const PortfolioImportPage = () => { {importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}

-
+
{workbookSummary.map(([key, value]) => (
{summaryLabels[key] || key}