diff --git a/backend/src/db/api/inventoryContext.js b/backend/src/db/api/inventoryContext.js new file mode 100644 index 0000000..cc15467 --- /dev/null +++ b/backend/src/db/api/inventoryContext.js @@ -0,0 +1,398 @@ +const db = require('../models'); + +const QueryTypes = db.Sequelize.QueryTypes; +const Op = db.Sequelize.Op; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const getCurrentOrganizationId = (currentUser) => { + if (!currentUser) { + return null; + } + + return ( + currentUser.organization?.id || + currentUser.organizations?.id || + currentUser.organizationId || + currentUser.organizationsId || + null + ); +}; + +const createBadRequestError = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const getImportCellValue = (item, keys) => { + for (const key of keys) { + const value = item?.[key]; + + if (value === 0 || value === false) { + return value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + continue; + } + + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + + return null; +}; + +const normalizeReferenceValue = (value) => { + if (value === undefined || value === null) { + return null; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + + return value; +}; + +const buildReferenceClauses = (reference, fields = []) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return []; + } + + const clauses = []; + + if (typeof normalizedReference === 'string' && UUID_PATTERN.test(normalizedReference)) { + clauses.push({ id: normalizedReference }); + } + + for (const field of fields) { + clauses.push({ + [field]: { + [Op.iLike]: String(normalizedReference), + }, + }); + } + + return clauses; +}; + +const buildLookupWhere = (reference, fields = [], extraWhere = {}) => { + const clauses = buildReferenceClauses(reference, fields); + + if (!clauses.length) { + return null; + } + + const filters = [{ [Op.or]: clauses }]; + + if (extraWhere && Object.keys(extraWhere).length) { + filters.unshift(extraWhere); + } + + if (filters.length === 1) { + return filters[0]; + } + + return { + [Op.and]: filters, + }; +}; + +const lookupSingleRecordByReference = async ({ + model, + label, + reference, + fields = [], + extraWhere = {}, + transaction, +}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const where = buildLookupWhere(normalizedReference, fields, extraWhere); + + if (!where) { + return null; + } + + const records = await model.findAll({ + where, + transaction, + limit: 2, + order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']], + }); + + if (!records.length) { + throw createBadRequestError(`${label} "${normalizedReference}" was not found.`); + } + + if (records.length > 1) { + throw createBadRequestError(`${label} "${normalizedReference}" matches multiple records. Use the ID instead.`); + } + + return records[0]; +}; + +const normalizeImportedBoolean = (value) => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'y'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'n'].includes(normalized)) { + return false; + } + } + + return value; +}; + +const normalizeInventoryImportRow = (item = {}) => ({ + ...item, + id: getImportCellValue(item, ['id']) || undefined, + importHash: getImportCellValue(item, ['importHash']) || null, + tenant: getImportCellValue(item, ['tenant', 'tenantId']), + organizations: getImportCellValue(item, ['organizations', 'organization', 'organizationsId', 'organizationId']), + property: getImportCellValue(item, ['property', 'propertyId']), + unit_type: getImportCellValue(item, ['unit_type', 'unitType', 'unit_typeId', 'unitTypeId']), + is_active: normalizeImportedBoolean(item?.is_active), +}); + +const prefixImportError = (error, rowNumber) => { + if (!error) { + return error; + } + + const prefixedMessage = `Import row ${rowNumber}: ${error.message}`; + + if (typeof error.message === 'string' && !error.message.startsWith(`Import row ${rowNumber}:`)) { + error.message = prefixedMessage; + } + + return error; +}; + +const resolveTenantIdForOrganization = async (organizationId, transaction) => { + if (!organizationId) { + return null; + } + + const rows = await db.sequelize.query( + ` + SELECT "tenants_organizationsId" AS "tenantId" + FROM "tenantsOrganizationsOrganizations" + WHERE "organizationId" = :organizationId + ORDER BY "updatedAt" DESC, "createdAt" DESC + LIMIT 1 + `, + { + replacements: { organizationId }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + return rows[0]?.tenantId || null; +}; + +const resolveOrganizationIdsForTenant = async (tenantId, transaction) => { + if (!tenantId) { + return []; + } + + const rows = await db.sequelize.query( + ` + SELECT "organizationId" + FROM "tenantsOrganizationsOrganizations" + WHERE "tenants_organizationsId" = :tenantId + ORDER BY "updatedAt" DESC, "createdAt" DESC + `, + { + replacements: { tenantId }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + return rows.map((row) => row.organizationId).filter(Boolean); +}; + +const resolveTenantReference = async (reference, transaction) => { + const tenant = await lookupSingleRecordByReference({ + model: db.tenants, + label: 'Tenant', + reference, + fields: ['slug', 'name'], + transaction, + }); + + return tenant?.id || null; +}; + +const resolveOrganizationReference = async (reference, { tenantId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const extraWhere = {}; + + if (tenantId) { + const organizationIds = await resolveOrganizationIdsForTenant(tenantId, transaction); + + if (!organizationIds.length) { + throw createBadRequestError('The selected tenant has no organizations available for import.'); + } + + extraWhere.id = { + [Op.in]: organizationIds, + }; + } + + const organization = await lookupSingleRecordByReference({ + model: db.organizations, + label: 'Organization', + reference: normalizedReference, + fields: ['name'], + extraWhere, + transaction, + }); + + return organization?.id || null; +}; + +const resolvePropertyReference = async (reference, { currentUser, organizationId, tenantId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const extraWhere = {}; + + if (organizationId) { + extraWhere.organizationsId = organizationId; + } else if (!globalAccess) { + const currentOrganizationId = getCurrentOrganizationId(currentUser); + + if (currentOrganizationId) { + extraWhere.organizationsId = currentOrganizationId; + } + } + + if (tenantId) { + extraWhere.tenantId = tenantId; + } + + const property = await lookupSingleRecordByReference({ + model: db.properties, + label: 'Property', + reference: normalizedReference, + fields: ['code', 'name'], + extraWhere, + transaction, + }); + + return property?.id || null; +}; + +const resolveUnitTypeReference = async (reference, { currentUser, organizationId, propertyId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const extraWhere = {}; + + if (propertyId) { + extraWhere.propertyId = propertyId; + } + + if (organizationId) { + extraWhere.organizationsId = organizationId; + } else if (!globalAccess) { + const currentOrganizationId = getCurrentOrganizationId(currentUser); + + if (currentOrganizationId) { + extraWhere.organizationsId = currentOrganizationId; + } + } + + const unitType = await lookupSingleRecordByReference({ + model: db.unit_types, + label: 'Unit type', + reference: normalizedReference, + fields: ['code', 'name'], + extraWhere, + transaction, + }); + + return unitType?.id || null; +}; + +const loadPropertyContext = async (propertyId, transaction) => { + if (!propertyId) { + return null; + } + + const property = await db.properties.findByPk(propertyId, { transaction }); + + if (!property) { + throw createBadRequestError('Selected property was not found.'); + } + + return property; +}; + +const loadUnitTypeContext = async (unitTypeId, transaction) => { + if (!unitTypeId) { + return null; + } + + const unitType = await db.unit_types.findByPk(unitTypeId, { transaction }); + + if (!unitType) { + throw createBadRequestError('Selected unit type was not found.'); + } + + return unitType; +}; + +module.exports = { + createBadRequestError, + getCurrentOrganizationId, + getImportCellValue, + loadPropertyContext, + loadUnitTypeContext, + normalizeImportedBoolean, + normalizeInventoryImportRow, + normalizeReferenceValue, + prefixImportError, + resolveOrganizationIdsForTenant, + resolveOrganizationReference, + resolvePropertyReference, + resolveTenantIdForOrganization, + resolveTenantReference, + resolveUnitTypeReference, +}; diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js index 7bf79e2..2101470 100644 --- a/backend/src/db/api/organizations.js +++ b/backend/src/db/api/organizations.js @@ -1,10 +1,7 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); - - +const { resolveOrganizationIdsForTenant } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -74,8 +71,6 @@ module.exports = class OrganizationsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - const organizations = await db.organizations.findByPk(id, {}, {transaction}); @@ -322,9 +317,6 @@ module.exports = class OrganizationsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -431,17 +423,30 @@ module.exports = class OrganizationsDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) { + const filters = []; + const organizationIdsForTenant = tenantId + ? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction) + : []; + if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ id: organizationId }); + } + + if (tenantId) { + if (!organizationIdsForTenant.length) { + return []; + } + + filters.push({ + id: { + [Op.in]: organizationIdsForTenant, + }, + }); } - if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -450,15 +455,19 @@ module.exports = class OrganizationsDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.organizations.findAll({ attributes: [ 'id', 'name' ], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/properties.js b/backend/src/db/api/properties.js index 4c5f83a..1badc7e 100644 --- a/backend/src/db/api/properties.js +++ b/backend/src/db/api/properties.js @@ -2,8 +2,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, prefixImportError, resolveOrganizationReference, resolveTenantIdForOrganization, resolveTenantReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -15,6 +14,17 @@ module.exports = class PropertiesDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + let organizationId = data.organizations || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } + + let tenantId = data.tenant || null; + if (!tenantId && organizationId) { + tenantId = await resolveTenantIdForOrganization(organizationId, transaction); + } const properties = await db.properties.create( { @@ -69,11 +79,11 @@ module.exports = class PropertiesDBApi { ); - await properties.setTenant( data.tenant || null, { + await properties.setTenant( tenantId || null, { transaction, }); - await properties.setOrganizations( data.organizations || null, { + await properties.setOrganizations( organizationId || null, { transaction, }); @@ -111,77 +121,90 @@ module.exports = class PropertiesDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const propertiesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - address: item.address - || - null - , - - city: item.city - || - null - , - - country: item.country - || - null - , - - timezone: item.timezone - || - null - , - - description: item.description - || - null - , - - is_active: item.is_active - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const properties = await db.properties.bulkCreate(propertiesData, { transaction }); + try { + const globalAccess = Boolean(currentUser.app_role?.globalAccess); + let tenantId = null; + let organizationId = null; - // For each item created, replace relation files - - for (let i = 0; i < properties.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.properties.getTableName(), - belongsToColumn: 'images', - belongsToId: properties[i].id, - }, - data[i].images, - options, - ); + if (globalAccess) { + tenantId = await resolveTenantReference(item.tenant, transaction); + organizationId = await resolveOrganizationReference(item.organizations, { + tenantId, + transaction, + }); + + if (!organizationId) { + throw createBadRequestError('Organization is required for property import.'); + } + } else { + organizationId = getCurrentOrganizationId(currentUser); + } + + if (!tenantId && organizationId) { + tenantId = await resolveTenantIdForOrganization(organizationId, transaction); + } + + item.tenant = tenantId || null; + item.organizations = organizationId || null; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.properties.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } } - - return properties; + return createdRecords; } static async update(id, data, options) { @@ -757,13 +780,21 @@ module.exports = class PropertiesDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, selectedOrganizationId) { const filters = []; if (!globalAccess && organizationId) { filters.push({ organizationsId: organizationId }); } + if (tenantId) { + filters.push({ tenantId: Utils.uuid(tenantId) }); + } + + if (selectedOrganizationId) { + filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) }); + } + if (query) { filters.push({ [Op.or]: [ @@ -786,7 +817,7 @@ module.exports = class PropertiesDBApi { where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/unit_types.js b/backend/src/db/api/unit_types.js index 550d6a7..cad0bb5 100644 --- a/backend/src/db/api/unit_types.js +++ b/backend/src/db/api/unit_types.js @@ -1,8 +1,7 @@ const db = require('../models'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, prefixImportError, resolvePropertyReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -14,6 +13,17 @@ module.exports = class Unit_typesDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + if (!data.property) { + throw createBadRequestError('Select a property before creating a unit type.'); + } + + const property = await loadPropertyContext(data.property || null, transaction); + + let organizationId = data.organizations || property?.organizationsId || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } const unit_types = await db.unit_types.create( { @@ -81,7 +91,7 @@ module.exports = class Unit_typesDBApi { transaction, }); - await unit_types.setOrganizations( data.organizations || null, { + await unit_types.setOrganizations( organizationId || null, { transaction, }); @@ -101,84 +111,98 @@ module.exports = class Unit_typesDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const unit_typesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - max_occupancy: item.max_occupancy - || - null - , - - bedrooms: item.bedrooms - || - null - , - - bathrooms: item.bathrooms - || - null - , - - size_sqm: item.size_sqm - || - null - , - - description: item.description - || - null - , - - base_nightly_rate: item.base_nightly_rate - || - null - , - - base_monthly_rate: item.base_monthly_rate - || - null - , - - minimum_stay_nights: item.minimum_stay_nights - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const unit_types = await db.unit_types.bulkCreate(unit_typesData, { transaction }); + try { + const propertyId = await resolvePropertyReference(item.property, { + currentUser, + transaction, + }); - // For each item created, replace relation files - + if (!propertyId) { + throw createBadRequestError('Property is required for unit type import.'); + } - return unit_types; + item.property = propertyId; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.unit_types.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } + } + + return createdRecords; } static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; const unit_types = await db.unit_types.findByPk(id, {}, {transaction}); + const nextPropertyId = data.property !== undefined ? data.property : unit_types?.propertyId; + if (!nextPropertyId) { + throw createBadRequestError('Select a property before saving this unit type.'); + } + const property = await loadPropertyContext(nextPropertyId || null, transaction); - + let organizationId = data.organizations; + if (organizationId === undefined && data.property !== undefined) { + organizationId = property?.organizationsId || null; + } + if ((organizationId === undefined || organizationId === null) && !globalAccess && !unit_types?.organizationsId) { + organizationId = getCurrentOrganizationId(currentUser); + } const updatePayload = {}; @@ -227,10 +251,10 @@ module.exports = class Unit_typesDBApi { ); } - if (data.organizations !== undefined) { + if (organizationId !== undefined) { await unit_types.setOrganizations( - data.organizations, + organizationId, { transaction } ); @@ -763,13 +787,17 @@ module.exports = class Unit_typesDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId) { const filters = []; if (!globalAccess && organizationId) { filters.push({ organizationsId: organizationId }); } + if (propertyId) { + filters.push({ propertyId: Utils.uuid(propertyId) }); + } + if (query) { filters.push({ [Op.or]: [ @@ -792,7 +820,7 @@ module.exports = class Unit_typesDBApi { where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/units.js b/backend/src/db/api/units.js index 5091f29..132803d 100644 --- a/backend/src/db/api/units.js +++ b/backend/src/db/api/units.js @@ -1,10 +1,7 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, loadUnitTypeContext, prefixImportError, resolvePropertyReference, resolveUnitTypeReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -16,6 +13,31 @@ module.exports = class UnitsDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + let propertyId = data.property || null; + const unitType = await loadUnitTypeContext(data.unit_type || null, transaction); + if (!propertyId && unitType?.propertyId) { + propertyId = unitType.propertyId; + } + + if (!propertyId) { + throw createBadRequestError('Select a property before creating a unit.'); + } + if (!data.unit_type) { + throw createBadRequestError('Select a unit type before creating a unit.'); + } + + const property = await loadPropertyContext(propertyId || null, transaction); + + if (property && unitType?.propertyId && unitType.propertyId !== property.id) { + throw createBadRequestError('Selected unit type does not belong to the selected property.'); + } + + let organizationId = data.organizations || property?.organizationsId || unitType?.organizationsId || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } const units = await db.units.create( { @@ -54,7 +76,7 @@ module.exports = class UnitsDBApi { ); - await units.setProperty( data.property || null, { + await units.setProperty( propertyId || null, { transaction, }); @@ -62,7 +84,7 @@ module.exports = class UnitsDBApi { transaction, }); - await units.setOrganizations( data.organizations || null, { + await units.setOrganizations( organizationId || null, { transaction, }); @@ -82,49 +104,89 @@ module.exports = class UnitsDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const unitsData = data.map((item, index) => ({ - id: item.id || undefined, - - unit_number: item.unit_number - || - null - , - - floor: item.floor - || - null - , - - status: item.status - || - null - , - - max_occupancy_override: item.max_occupancy_override - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const units = await db.units.bulkCreate(unitsData, { transaction }); + try { + const propertyId = await resolvePropertyReference(item.property, { + currentUser, + transaction, + }); - // For each item created, replace relation files - + if (!propertyId) { + throw createBadRequestError('Property is required for unit import.'); + } - return units; + const property = await loadPropertyContext(propertyId, transaction); + const unitTypeId = await resolveUnitTypeReference(item.unit_type, { + currentUser, + organizationId: property?.organizationsId || null, + propertyId, + transaction, + }); + + if (!unitTypeId) { + throw createBadRequestError('Unit type is required for unit import.'); + } + + item.property = propertyId; + item.unit_type = unitTypeId; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.units.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } + } + + return createdRecords; } static async update(id, data, options) { @@ -133,9 +195,30 @@ module.exports = class UnitsDBApi { const globalAccess = currentUser.app_role?.globalAccess; const units = await db.units.findByPk(id, {}, {transaction}); + const nextPropertyId = data.property !== undefined ? data.property : units?.propertyId; + const nextUnitTypeId = data.unit_type !== undefined ? data.unit_type : units?.unit_typeId; + const unitType = await loadUnitTypeContext(nextUnitTypeId || null, transaction); + const propertyId = nextPropertyId || unitType?.propertyId || null; + if (!propertyId) { + throw createBadRequestError('Select a property before saving this unit.'); + } + if (!nextUnitTypeId) { + throw createBadRequestError('Select a unit type before saving this unit.'); + } + const property = await loadPropertyContext(propertyId || null, transaction); - + if (property && unitType?.propertyId && unitType.propertyId !== property.id) { + throw createBadRequestError('Selected unit type does not belong to the selected property.'); + } + + let organizationId = data.organizations; + if (organizationId === undefined && (data.property !== undefined || data.unit_type !== undefined)) { + organizationId = property?.organizationsId || unitType?.organizationsId || null; + } + if ((organizationId === undefined || organizationId === null) && !globalAccess && !units?.organizationsId) { + organizationId = getCurrentOrganizationId(currentUser); + } const updatePayload = {}; @@ -160,10 +243,10 @@ module.exports = class UnitsDBApi { - if (data.property !== undefined) { + if (data.property !== undefined || (data.unit_type !== undefined && propertyId !== units?.propertyId)) { await units.setProperty( - data.property, + propertyId, { transaction } ); @@ -178,10 +261,10 @@ module.exports = class UnitsDBApi { ); } - if (data.organizations !== undefined) { + if (organizationId !== undefined) { await units.setOrganizations( - data.organizations, + organizationId, { transaction } ); @@ -349,9 +432,6 @@ module.exports = class UnitsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -597,17 +677,23 @@ module.exports = class UnitsDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) { + const filters = []; if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ organizationsId: organizationId }); } + if (propertyId) { + filters.push({ propertyId: Utils.uuid(propertyId) }); + } + + if (unitTypeId) { + filters.push({ unit_typeId: Utils.uuid(unitTypeId) }); + } if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -616,15 +702,19 @@ module.exports = class UnitsDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.units.findAll({ attributes: [ 'id', 'unit_number' ], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['unit_number', 'ASC']], + order: [['unit_number', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/routes/organizations.js b/backend/src/routes/organizations.js index 6f96a1d..e51987d 100644 --- a/backend/src/routes/organizations.js +++ b/backend/src/routes/organizations.js @@ -5,8 +5,6 @@ const OrganizationsService = require('../services/organizations'); const OrganizationsDBApi = require('../db/api/organizations'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); @@ -377,17 +375,16 @@ router.get('/count', wrapAsync(async (req, res) => { * description: Some server error */ router.get('/autocomplete', async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; + const payload = await OrganizationsDBApi.findAllAutocomplete( req.query.query, req.query.limit, req.query.offset, - globalAccess, organizationId, + globalAccess, + organizationId, + req.query.tenantId || req.query.tenant, ); res.status(200).send(payload); diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js index c43d894..fb820fd 100644 --- a/backend/src/routes/properties.js +++ b/backend/src/routes/properties.js @@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync; const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -17,7 +18,7 @@ const { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id; + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; const payload = await PropertiesDBApi.findAllAutocomplete( req.query.query, @@ -25,6 +26,8 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re req.query.offset, globalAccess, organizationId, + req.query.tenantId || req.query.tenant, + req.query.organizationsId || req.query.organizationId || req.query.organizations, ); res.status(200).send(payload); @@ -150,6 +153,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('properties', req.currentUser); + const csv = buildInventoryImportTemplateCsv('properties', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/routes/unit_types.js b/backend/src/routes/unit_types.js index fc9c413..d1ef374 100644 --- a/backend/src/routes/unit_types.js +++ b/backend/src/routes/unit_types.js @@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync; const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -17,7 +18,7 @@ const { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id; + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; const payload = await Unit_typesDBApi.findAllAutocomplete( req.query.query, @@ -25,6 +26,7 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re req.query.offset, globalAccess, organizationId, + req.query.propertyId || req.query.property, ); res.status(200).send(payload); @@ -159,6 +161,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('unit_types', req.currentUser); + const csv = buildInventoryImportTemplateCsv('unit_types', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/routes/units.js b/backend/src/routes/units.js index 54c23cb..829e2aa 100644 --- a/backend/src/routes/units.js +++ b/backend/src/routes/units.js @@ -5,12 +5,11 @@ const UnitsService = require('../services/units'); const UnitsDBApi = require('../db/api/units'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -129,6 +128,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('units', req.currentUser); + const csv = buildInventoryImportTemplateCsv('units', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); @@ -387,17 +395,17 @@ router.get('/count', wrapAsync(async (req, res) => { * description: Some server error */ router.get('/autocomplete', async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; + const payload = await UnitsDBApi.findAllAutocomplete( req.query.query, req.query.limit, req.query.offset, - globalAccess, organizationId, + globalAccess, + organizationId, + req.query.propertyId || req.query.property, + req.query.unitTypeId || req.query.unit_type || req.query.unitType, ); res.status(200).send(payload); diff --git a/backend/src/services/importer.js b/backend/src/services/importer.js new file mode 100644 index 0000000..86873c0 --- /dev/null +++ b/backend/src/services/importer.js @@ -0,0 +1,152 @@ +const csv = require('csv-parser'); +const stream = require('stream'); + +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); + +const ALLOWED_CSV_MIME_TYPES = new Set([ + 'text/csv', + 'application/csv', + 'application/vnd.ms-excel', + 'text/plain', + 'application/octet-stream', +]); + +const isCsvFilename = (filename = '') => filename.toLowerCase().endsWith('.csv'); + +const isBlankRow = (row = {}) => + Object.values(row).every((value) => value === undefined || value === null || String(value).trim() === ''); + +const createCsvValidationError = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const splitCsvHeaderLine = (line = '') => { + const headers = []; + let current = ''; + let insideQuotes = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + const nextCharacter = line[index + 1]; + + if (character === '"') { + if (insideQuotes && nextCharacter === '"') { + current += '"'; + index += 1; + } else { + insideQuotes = !insideQuotes; + } + continue; + } + + if (character === ',' && !insideQuotes) { + headers.push(current.trim()); + current = ''; + continue; + } + + current += character; + } + + headers.push(current.trim()); + + return headers + .map((header) => header.replace(/^\uFEFF/, '').trim()) + .filter((header) => header.length); +}; + +const getCsvHeadersFromBuffer = (buffer) => { + const fileContents = Buffer.from(buffer).toString('utf8').replace(/^\uFEFF/, ''); + const firstNonEmptyLine = fileContents + .split(/\r?\n/) + .find((line) => line.trim().length > 0); + + if (!firstNonEmptyLine) { + return []; + } + + return splitCsvHeaderLine(firstNonEmptyLine); +}; + +const validateCsvHeaders = (headers = [], options = {}) => { + const allowedHeaders = (options.allowedHeaders || options.columns || []).map((header) => header.trim()); + const requiredHeaders = (options.requiredHeaders || []).map((header) => header.trim()); + + if (!allowedHeaders.length) { + return; + } + + const unknownHeaders = headers.filter((header) => !allowedHeaders.includes(header)); + const missingRequiredHeaders = requiredHeaders.filter((header) => !headers.includes(header)); + + if (!unknownHeaders.length && !missingRequiredHeaders.length) { + return; + } + + const details = []; + + if (missingRequiredHeaders.length) { + details.push(`Missing required columns: ${missingRequiredHeaders.join(', ')}`); + } + + if (unknownHeaders.length) { + details.push(`Unexpected columns: ${unknownHeaders.join(', ')}`); + } + + details.push(`Allowed columns: ${allowedHeaders.join(', ')}`); + + throw createCsvValidationError(details.join('. ')); +}; + +const parseCsvImportFile = async (req, res, options = {}) => { + await processFile(req, res); + + if (!req.file || !req.file.buffer || !req.file.buffer.length) { + throw new ValidationError('importer.errors.invalidFileEmpty'); + } + + const filename = req.file.originalname || req.body?.filename || ''; + const mimetype = req.file.mimetype || ''; + + if (!isCsvFilename(filename) && !ALLOWED_CSV_MIME_TYPES.has(mimetype)) { + throw new ValidationError('importer.errors.invalidFileUpload'); + } + + const csvHeaders = getCsvHeadersFromBuffer(req.file.buffer); + validateCsvHeaders(csvHeaders, options); + + const bufferStream = new stream.PassThrough(); + const results = []; + + bufferStream.end(Buffer.from(req.file.buffer)); + + await new Promise((resolve, reject) => { + bufferStream + .pipe( + csv({ + mapHeaders: ({ header }) => (header ? header.trim() : header), + mapValues: ({ value }) => (typeof value === 'string' ? value.trim() : value), + }), + ) + .on('data', (data) => { + if (!isBlankRow(data)) { + results.push(data); + } + }) + .on('end', resolve) + .on('error', reject); + }); + + if (!results.length) { + throw new ValidationError('importer.errors.invalidFileEmpty'); + } + + return results; +}; + +module.exports = { + parseCsvImportFile, +}; diff --git a/backend/src/services/inventoryImportTemplates.js b/backend/src/services/inventoryImportTemplates.js new file mode 100644 index 0000000..9157564 --- /dev/null +++ b/backend/src/services/inventoryImportTemplates.js @@ -0,0 +1,72 @@ +const { parse } = require('json2csv'); + +const getTemplateForProperties = (currentUser) => { + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const columns = globalAccess + ? ['tenant', 'organization', 'name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active'] + : ['name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active']; + + return { + columns, + requiredColumns: globalAccess ? ['organization'] : [], + fileName: 'properties-import-template.csv', + }; +}; + +const getTemplateForUnitTypes = () => ({ + columns: [ + 'property', + 'name', + 'code', + 'description', + 'max_occupancy', + 'bedrooms', + 'bathrooms', + 'minimum_stay_nights', + 'size_sqm', + 'base_nightly_rate', + 'base_monthly_rate', + ], + requiredColumns: ['property'], + fileName: 'unit-types-import-template.csv', +}); + +const getTemplateForUnits = () => ({ + columns: [ + 'property', + 'unit_type', + 'unit_number', + 'floor', + 'status', + 'max_occupancy_override', + 'notes', + ], + requiredColumns: ['property', 'unit_type'], + fileName: 'units-import-template.csv', +}); + +const TEMPLATE_FACTORIES = { + properties: getTemplateForProperties, + unit_types: getTemplateForUnitTypes, + units: getTemplateForUnits, +}; + +const getInventoryImportTemplate = (entity, currentUser) => { + const factory = TEMPLATE_FACTORIES[entity]; + + if (!factory) { + throw new Error(`Unknown inventory import template entity: ${entity}`); + } + + return factory(currentUser); +}; + +const buildInventoryImportTemplateCsv = (entity, currentUser) => { + const template = getInventoryImportTemplate(entity, currentUser); + return parse([], { fields: template.columns }); +}; + +module.exports = { + buildInventoryImportTemplateCsv, + getInventoryImportTemplate, +}; diff --git a/backend/src/services/properties.js b/backend/src/services/properties.js index ccbd4c6..4d84d1d 100644 --- a/backend/src/services/properties.js +++ b/backend/src/services/properties.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const PropertiesDBApi = require('../db/api/properties'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class PropertiesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('properties', req.currentUser), + ); await PropertiesDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class PropertiesService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class PropertiesService { } } - -}; +} diff --git a/backend/src/services/unit_types.js b/backend/src/services/unit_types.js index ba034a3..449c4a5 100644 --- a/backend/src/services/unit_types.js +++ b/backend/src/services/unit_types.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const Unit_typesDBApi = require('../db/api/unit_types'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class Unit_typesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('unit_types', req.currentUser), + ); await Unit_typesDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class Unit_typesService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class Unit_typesService { } } - -}; +} diff --git a/backend/src/services/units.js b/backend/src/services/units.js index b4a984a..c333e2d 100644 --- a/backend/src/services/units.js +++ b/backend/src/services/units.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const UnitsDBApi = require('../db/api/units'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class UnitsService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('units', req.currentUser), + ); await UnitsDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class UnitsService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class UnitsService { } } - -}; +} diff --git a/frontend/src/components/InventoryImportGuidance.tsx b/frontend/src/components/InventoryImportGuidance.tsx new file mode 100644 index 0000000..698c23c --- /dev/null +++ b/frontend/src/components/InventoryImportGuidance.tsx @@ -0,0 +1,57 @@ +import { InventoryTemplateConfig } from '../helpers/inventoryImportTemplates'; + +type Props = { + config: InventoryTemplateConfig; +}; + +const InventoryImportGuidance = ({ config }: Props) => { + const exampleCsvHeader = config.columns.join(','); + const exampleCsvRow = config.columns + .map((column) => config.exampleRow.find((entry) => entry.column === column)?.value || '') + .join(','); + + return ( +
+

Use the import template for this workflow

+

{config.workflowHint}

+

{config.referenceHint}

+

+ Allowed columns: {config.columns.join(', ')} +

+

+ Required relationship columns:{' '} + {config.requiredColumns.length ? config.requiredColumns.join(', ') : 'None'} +

+ + {config.exampleReferences.length > 0 && ( +
+

Reference examples

+ +
+ )} + +
+

Example row

+

+ Replace these example values with real records from your workspace before uploading. +

+
+          {`${exampleCsvHeader}
+${exampleCsvRow}`}
+        
+
+
+ ); +}; + +export default InventoryImportGuidance; diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index 1a7f1b7..7e43604 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -28,7 +28,7 @@ const buildQueryString = (itemRef, queryParams) => { return params.toString(); }; -export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams }) => { +export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams, placeholder, noOptionsMessage }) => { const [value, setValue] = useState(null) const PAGE_SIZE = 100; const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]); @@ -58,6 +58,18 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled setValue(option) } + const getNoOptionsMessage = ({ inputValue }: { inputValue: string }) => { + if (typeof noOptionsMessage === 'function') { + return noOptionsMessage({ inputValue, itemRef }) + } + + if (typeof noOptionsMessage === 'string') { + return inputValue ? `No matching results for "${inputValue}"` : noOptionsMessage + } + + return inputValue ? `No matching results for "${inputValue}"` : 'No options available yet' + } + async function callApi(inputValue: string, loadedOptions: any[]) { const params = new URLSearchParams(extraQueryString); params.set('limit', String(PAGE_SIZE)); @@ -88,6 +100,8 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled defaultOptions isDisabled={disabled} isClearable + placeholder={placeholder || 'Select...'} + noOptionsMessage={getNoOptionsMessage} /> ) } diff --git a/frontend/src/helpers/inventoryImportTemplates.ts b/frontend/src/helpers/inventoryImportTemplates.ts new file mode 100644 index 0000000..fa09629 --- /dev/null +++ b/frontend/src/helpers/inventoryImportTemplates.ts @@ -0,0 +1,186 @@ +import axios from 'axios'; + +type InventoryImportEntity = 'properties' | 'unit_types' | 'units'; + +type InventoryTemplateExample = { + column: string; + value: string; + note?: string; +}; + +export type InventoryTemplateConfig = { + columns: string[]; + requiredColumns: string[]; + fileName: string; + workflowHint: string; + referenceHint: string; + exampleReferences: InventoryTemplateExample[]; + exampleRow: InventoryTemplateExample[]; +}; + +const triggerBlobDownload = (blob: Blob, fileName: string) => { + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = fileName; + link.click(); + window.URL.revokeObjectURL(link.href); +}; + +const getFileNameFromHeaders = (contentDisposition?: string) => { + const match = contentDisposition?.match(/filename="?([^";]+)"?/i); + return match?.[1] || null; +}; + +export const downloadInventoryImportTemplate = async ( + entity: InventoryImportEntity, + fallbackFileName: string, +) => { + const response = await axios({ + url: `/${entity}/import-template`, + method: 'GET', + responseType: 'blob', + }); + + const fileName = getFileNameFromHeaders(response.headers['content-disposition']) || fallbackFileName; + const type = response.headers['content-type'] || 'text/csv'; + const blob = new Blob([response.data], { type }); + + triggerBlobDownload(blob, fileName); +}; + +export const getInventoryImportTemplateConfig = ( + entity: InventoryImportEntity, + currentUser: any, +): InventoryTemplateConfig => { + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + + switch (entity) { + case 'properties': + return { + columns: globalAccess + ? ['tenant', 'organization', 'name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active'] + : ['name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active'], + requiredColumns: globalAccess ? ['organization'] : [], + fileName: 'properties-import-template.csv', + workflowHint: globalAccess + ? 'Use tenant and organization only when you are setting up customer workspaces as a global admin.' + : 'Tenant and organization are assigned automatically from your current workspace, so they are not part of this template.', + referenceHint: globalAccess + ? 'Tenant accepts an existing tenant ID, slug, or name. Organization accepts an existing organization ID or name within that tenant.' + : 'Each row creates one property inside your current workspace.', + exampleReferences: globalAccess + ? [ + { + column: 'tenant', + value: 'acme', + note: 'Example slug. You can also use the tenant UUID or tenant name.', + }, + { + column: 'organization', + value: 'Acme Hospitality', + note: 'Use an existing organization ID or organization name inside the selected tenant.', + }, + ] + : [], + exampleRow: globalAccess + ? [ + { column: 'tenant', value: 'acme' }, + { column: 'organization', value: 'Acme Hospitality' }, + { column: 'name', value: 'Sunset Suites' }, + { column: 'code', value: 'SUNSET-SF' }, + { column: 'address', value: '100 Market St' }, + { column: 'city', value: 'San Francisco' }, + { column: 'country', value: 'USA' }, + { column: 'timezone', value: 'America/Los_Angeles' }, + { column: 'description', value: 'Downtown furnished stay' }, + { column: 'is_active', value: 'true' }, + ] + : [ + { column: 'name', value: 'Sunset Suites' }, + { column: 'code', value: 'SUNSET-SF' }, + { column: 'address', value: '100 Market St' }, + { column: 'city', value: 'San Francisco' }, + { column: 'country', value: 'USA' }, + { column: 'timezone', value: 'America/Los_Angeles' }, + { column: 'description', value: 'Downtown furnished stay' }, + { column: 'is_active', value: 'true' }, + ], + }; + case 'unit_types': + return { + columns: [ + 'property', + 'name', + 'code', + 'description', + 'max_occupancy', + 'bedrooms', + 'bathrooms', + 'minimum_stay_nights', + 'size_sqm', + 'base_nightly_rate', + 'base_monthly_rate', + ], + requiredColumns: ['property'], + fileName: 'unit-types-import-template.csv', + workflowHint: 'Import unit types only after the property already exists.', + referenceHint: 'The property column can use an existing property ID, code, or name from your workspace.', + exampleReferences: [ + { + column: 'property', + value: 'SUNSET-SF', + note: 'Example property code. You can also use the property UUID or property name.', + }, + ], + exampleRow: [ + { column: 'property', value: 'SUNSET-SF' }, + { column: 'name', value: 'Studio Deluxe' }, + { column: 'code', value: 'STD-DELUXE' }, + { column: 'description', value: 'Large studio with kitchenette' }, + { column: 'max_occupancy', value: '2' }, + { column: 'bedrooms', value: '0' }, + { column: 'bathrooms', value: '1' }, + { column: 'minimum_stay_nights', value: '2' }, + { column: 'size_sqm', value: '38' }, + { column: 'base_nightly_rate', value: '189.00' }, + { column: 'base_monthly_rate', value: '4200.00' }, + ], + }; + case 'units': + return { + columns: ['property', 'unit_type', 'unit_number', 'floor', 'status', 'max_occupancy_override', 'notes'], + requiredColumns: ['property', 'unit_type'], + fileName: 'units-import-template.csv', + workflowHint: 'Import units only after both the property and the unit type already exist.', + referenceHint: 'The property column can use a property ID, code, or name. The unit_type column can use a unit type ID, code, or name within that property.', + exampleReferences: [ + { + column: 'property', + value: 'SUNSET-SF', + note: 'Example property code. You can also use the property UUID or property name.', + }, + { + column: 'unit_type', + value: 'STD-DELUXE', + note: 'Example unit type code within that property. You can also use the unit type UUID or unit type name.', + }, + { + column: 'status', + value: 'available', + note: 'Valid statuses: available, occupied, maintenance, cleaning_hold, reserved, out_of_service.', + }, + ], + exampleRow: [ + { column: 'property', value: 'SUNSET-SF' }, + { column: 'unit_type', value: 'STD-DELUXE' }, + { column: 'unit_number', value: '405' }, + { column: 'floor', value: '4' }, + { column: 'status', value: 'available' }, + { column: 'max_occupancy_override', value: '2' }, + { column: 'notes', value: 'Corner unit with city view' }, + ], + }; + default: + throw new Error(`Unknown inventory import entity: ${entity}`); + } +}; diff --git a/frontend/src/helpers/inventoryWorkspace.ts b/frontend/src/helpers/inventoryWorkspace.ts new file mode 100644 index 0000000..4267d2c --- /dev/null +++ b/frontend/src/helpers/inventoryWorkspace.ts @@ -0,0 +1,36 @@ +import { getRoleLaneFromUser } from './roleLanes' + +export const canManageInventoryWorkspace = (currentUser?: any) => + getRoleLaneFromUser(currentUser) === 'super_admin' + +export const getCurrentOrganizationOption = (currentUser?: any) => { + const organization = currentUser?.organizations || currentUser?.organization || null + const id = organization?.id || currentUser?.organizationsId || currentUser?.organizationId || '' + const name = + organization?.name || + currentUser?.organizationName || + currentUser?.organizations?.name || + currentUser?.organization?.name || + 'Current organization' + + if (!id) { + return null + } + + return { + id, + name, + } +} + +export const getRelationId = (value?: any) => { + if (!value) { + return '' + } + + if (typeof value === 'string') { + return value + } + + return value.id || value.value || '' +} diff --git a/frontend/src/pages/booking_requests/booking_requests-edit.tsx b/frontend/src/pages/booking_requests/booking_requests-edit.tsx index 9d32008..caf1dc2 100644 --- a/frontend/src/pages/booking_requests/booking_requests-edit.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-edit.tsx @@ -168,11 +168,27 @@ const EditBooking_requestsPage = () => { - + - + diff --git a/frontend/src/pages/booking_requests/booking_requests-new.tsx b/frontend/src/pages/booking_requests/booking_requests-new.tsx index 0eceb68..1dc8758 100644 --- a/frontend/src/pages/booking_requests/booking_requests-new.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-new.tsx @@ -126,11 +126,27 @@ const Booking_requestsNew = () => { - + - + diff --git a/frontend/src/pages/properties/properties-edit.tsx b/frontend/src/pages/properties/properties-edit.tsx index 60dce18..dce01bd 100644 --- a/frontend/src/pages/properties/properties-edit.tsx +++ b/frontend/src/pages/properties/properties-edit.tsx @@ -1,469 +1,99 @@ import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' import Head from 'next/head' -import React, { ReactElement, useEffect, useState } from 'react' -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -import dayjs from "dayjs"; +import React, { ReactElement, useEffect, useRef, useState } from 'react' +import { Field, Form, Formik, useFormikContext } from 'formik' +import { useRouter } from 'next/router' +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' +import FormImagePicker from '../../components/FormImagePicker' +import { RichTextField } from '../../components/RichTextField' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SelectField } from "../../components/SelectField"; -import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SelectField } from '../../components/SelectField' import { SwitchField } from '../../components/SwitchField' -import {RichTextField} from "../../components/RichTextField"; - -import { update, fetch } from '../../stores/properties/propertiesSlice' +import { getPageTitle } from '../../config' +import { canManageInventoryWorkspace, getCurrentOrganizationOption, getRelationId } from '../../helpers/inventoryWorkspace' +import LayoutAuthenticated from '../../layouts/Authenticated' import { useAppDispatch, useAppSelector } from '../../stores/hooks' -import { useRouter } from 'next/router' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; +import { fetch, update } from '../../stores/properties/propertiesSlice' -import {hasPermission} from "../../helpers/userPermissions"; +const emptyInitialValues = { + id: '', + tenant: null, + name: '', + code: '', + address: '', + city: '', + country: '', + timezone: '', + description: '', + images: [], + is_active: false, + organizations: null, +} +const PropertyWorkspaceSync = () => { + const { values, setFieldValue } = useFormikContext() + const previousTenantRef = useRef(values.tenant) + useEffect(() => { + if (previousTenantRef.current && previousTenantRef.current !== values.tenant) { + setFieldValue('organizations', null) + } + + previousTenantRef.current = values.tenant + }, [setFieldValue, values.tenant]) + + return null +} const EditPropertiesPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - - tenant: null, - - - - - - 'name': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'code': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - address: '', - - - - - - - - - - - - - - - - - - - - - - - - - - 'city': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'country': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'timezone': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - description: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - images: [], - - - - - - - - - - - - - - - - - - - - is_active: false, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - unit_types: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - units: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - amenities: [], - - - - - - - - - - - - - - - - - - - - - - - - - - organizations: null, - - - - - - } - const [initialValues, setInitialValues] = useState(initVals) - const { properties } = useAppSelector((state) => state.properties) - - const { currentUser } = useAppSelector((state) => state.auth); - + const { currentUser } = useAppSelector((state) => state.auth) + const [initialValues, setInitialValues] = useState(emptyInitialValues) + const canManageWorkspace = canManageInventoryWorkspace(currentUser) + const currentOrganization = getCurrentOrganizationOption(currentUser) const { id } = router.query useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) - - useEffect(() => { - if (typeof properties === 'object') { - setInitialValues(properties) + if (!router.isReady || typeof id !== 'string') { + return } - }, [properties]) + + dispatch(fetch({ id })) + }, [dispatch, id, router.isReady]) useEffect(() => { - if (typeof properties === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (properties)[el]) - setInitialValues(newInitialVal); - } + if (!properties || Array.isArray(properties) || typeof properties !== 'object') { + return + } + + setInitialValues({ + ...emptyInitialValues, + ...properties, + }) }, [properties]) - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + const handleSubmit = async (values: typeof emptyInitialValues) => { + if (typeof id !== 'string') { + return + } + + const payload: Record = { ...values } + + if (!canManageWorkspace) { + delete payload.tenant + delete payload.organizations + } + + await dispatch(update({ id, data: payload })) await router.push('/properties/properties-list') } @@ -472,864 +102,127 @@ const EditPropertiesPage = () => { {getPageTitle('Edit Property')} + - - {''} + + {''} - - handleSubmit(values)} - > -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - router.push('/properties/properties-list')}/> - - + + + {({ values }) => ( +
+ + +
+ Keep the property record focused on property details. Manage unit types, units, and amenities from their own modules after this property is saved. +
+ + {!canManageWorkspace ? ( +
+ This property remains tied to your current workspace{currentOrganization ? ` (${currentOrganization.name})` : ''}. Tenant and organization assignment stay managed automatically. +
+ ) : null} + +
+ {canManageWorkspace ? ( + <> + + + + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + +
+ + + + + + + router.push('/properties/properties-list')} /> + + + )}
@@ -1338,15 +231,7 @@ const EditPropertiesPage = () => { } EditPropertiesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default EditPropertiesPage diff --git a/frontend/src/pages/properties/properties-list.tsx b/frontend/src/pages/properties/properties-list.tsx index 40dc049..6018cd2 100644 --- a/frontend/src/pages/properties/properties-list.tsx +++ b/frontend/src/pages/properties/properties-list.tsx @@ -14,9 +14,11 @@ import axios from "axios"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; import { humanize } from "../../helpers/humanize"; @@ -29,6 +31,7 @@ const PropertiesTablesPage = () => { const { currentUser } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('properties', currentUser); const [filters] = useState(([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'}, {label: 'Tenant', title: 'tenant'}, @@ -61,6 +64,10 @@ const PropertiesTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('properties', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -106,6 +113,7 @@ const PropertiesTablesPage = () => { ) : null} + {hasCreatePermission ? ( setIsModalActive(true)} /> ) : null} @@ -136,6 +144,8 @@ const PropertiesTablesPage = () => { onConfirm={onModalConfirm} onCancel={onModalCancel} > + + { + const { values, setFieldValue } = useFormikContext() + const previousTenantRef = useRef(values.tenant) + + useEffect(() => { + if (previousTenantRef.current && previousTenantRef.current !== values.tenant) { + setFieldValue('organizations', null) + } + + previousTenantRef.current = values.tenant + }, [setFieldValue, values.tenant]) + + return null +} const PropertiesNew = () => { const router = useRouter() const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) - - + const canManageWorkspace = canManageInventoryWorkspace(currentUser) + const currentOrganization = getCurrentOrganizationOption(currentUser) - const handleSubmit = async (data) => { - await dispatch(create(data)) + const handleSubmit = async (values: typeof initialValues) => { + const payload: Record = { ...values } + + if (!canManageWorkspace) { + delete payload.tenant + payload.organizations = currentOrganization?.id || null + } + + await dispatch(create(payload)) await router.push('/properties/properties-list') } + return ( <> {getPageTitle('New Property')} + - {''} + {''} - - handleSubmit(values)} - > -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - router.push('/properties/properties-list')}/> - - + + + {({ values }) => ( +
+ + +
+ Create the property first. Add unit types, units, and amenities after the property exists so the setup follows the real customer workflow. +
+ + {!canManageWorkspace ? ( +
+ This property will be saved inside your current workspace{currentOrganization ? ` (${currentOrganization.name})` : ''}. Tenant and organization are assigned automatically. +
+ ) : null} + +
+ {canManageWorkspace ? ( + <> + + + + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + +
+ + + + + + + router.push('/properties/properties-list')} /> + + + )}
@@ -788,15 +202,7 @@ const PropertiesNew = () => { } PropertiesNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default PropertiesNew diff --git a/frontend/src/pages/properties/properties-table.tsx b/frontend/src/pages/properties/properties-table.tsx index b09e584..a5e8bc3 100644 --- a/frontend/src/pages/properties/properties-table.tsx +++ b/frontend/src/pages/properties/properties-table.tsx @@ -14,9 +14,11 @@ import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,6 +34,7 @@ const PropertiesTablesPage = () => { const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('properties', currentUser); const [filters] = useState([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'}, @@ -74,6 +77,10 @@ const PropertiesTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('properties', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -107,6 +114,7 @@ const PropertiesTablesPage = () => { onClick={addFilter} /> + {hasCreatePermission && ( { onConfirm={onModalConfirm} onCancel={onModalCancel} > + + { +const EditUnitTypesPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - - property: null, - - - - - - 'name': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'code': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - max_occupancy: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - bedrooms: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - bathrooms: '', - - - - - - - - - - - - - - - - - - - - - - 'size_sqm': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - description: '', - - - - - - - - - - - - - - - - - - - - - - - - 'base_nightly_rate': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'base_monthly_rate': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - minimum_stay_nights: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - units: [], - - - - - - - - - - - - - - - - - - - - - - - - - - organizations: null, - - - - - - } - const [initialValues, setInitialValues] = useState(initVals) - const { unit_types } = useAppSelector((state) => state.unit_types) - - const { currentUser } = useAppSelector((state) => state.auth); - - + const [initialValues, setInitialValues] = useState(emptyInitialValues) const { id } = router.query useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) - - useEffect(() => { - if (typeof unit_types === 'object') { - setInitialValues(unit_types) + if (!router.isReady || typeof id !== 'string') { + return } - }, [unit_types]) + + dispatch(fetch({ id })) + }, [dispatch, id, router.isReady]) useEffect(() => { - if (typeof unit_types === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (unit_types)[el]) - setInitialValues(newInitialVal); - } + if (!unit_types || Array.isArray(unit_types) || typeof unit_types !== 'object') { + return + } + + setInitialValues({ + ...emptyInitialValues, + ...unit_types, + }) }, [unit_types]) - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + const handleSubmit = async (values: typeof emptyInitialValues) => { + if (typeof id !== 'string') { + return + } + + await dispatch(update({ id, data: values })) await router.push('/unit_types/unit_types-list') } return ( <> - {getPageTitle('Edit unit_types')} + {getPageTitle('Edit Unit Type')} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ Keep this screen focused on the unit type template. Manage actual units separately once the template is correct. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ - + - router.push('/unit_types/unit_types-list')}/> + router.push('/unit_types/unit_types-list')} />
@@ -1151,16 +158,8 @@ const EditUnit_typesPage = () => { ) } -EditUnit_typesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +EditUnitTypesPage.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default EditUnit_typesPage +export default EditUnitTypesPage diff --git a/frontend/src/pages/unit_types/unit_types-list.tsx b/frontend/src/pages/unit_types/unit_types-list.tsx index dac9a94..5fc41e5 100644 --- a/frontend/src/pages/unit_types/unit_types-list.tsx +++ b/frontend/src/pages/unit_types/unit_types-list.tsx @@ -14,9 +14,11 @@ import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => { const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser); const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'}, @@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('unit_types', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => { onClick={addFilter} /> + {hasCreatePermission && ( { onConfirm={onModalConfirm} onCancel={onModalCancel} > + + { +const UnitTypesNew = () => { const router = useRouter() const dispatch = useAppDispatch() - - - - const handleSubmit = async (data) => { - await dispatch(create(data)) + const handleSubmit = async (values: typeof initialValues) => { + await dispatch(create(values)) await router.push('/unit_types/unit_types-list') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle('New Unit Type')} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ Unit types are templates under a property, like Studio or Two Bedroom Deluxe. Create the actual units after this template is saved. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ - + - router.push('/unit_types/unit_types-list')}/> + router.push('/unit_types/unit_types-list')} />
@@ -737,16 +130,8 @@ const Unit_typesNew = () => { ) } -Unit_typesNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +UnitTypesNew.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default Unit_typesNew +export default UnitTypesNew diff --git a/frontend/src/pages/unit_types/unit_types-table.tsx b/frontend/src/pages/unit_types/unit_types-table.tsx index 7979f62..59c9248 100644 --- a/frontend/src/pages/unit_types/unit_types-table.tsx +++ b/frontend/src/pages/unit_types/unit_types-table.tsx @@ -14,9 +14,11 @@ import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => { const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser); const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'}, @@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('unit_types', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => { onClick={addFilter} /> + {hasCreatePermission && ( { onConfirm={onModalConfirm} onCancel={onModalCancel} > + + { + const { values, setFieldValue } = useFormikContext() + const previousPropertyRef = useRef(values.property) + useEffect(() => { + if (previousPropertyRef.current && previousPropertyRef.current !== values.property) { + setFieldValue('unit_type', null) + } + + previousPropertyRef.current = values.property + }, [setFieldValue, values.property]) + + return null +} const EditUnitsPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - - property: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - unit_type: null, - - - - - - 'unit_number': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'floor': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - status: '', - - - - - - - - - - - - - - - - - - max_occupancy_override: '', - - - - - - - - - - - - - - - - - - - - - - - - notes: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - availability_blocks: [], - - - - - - - - - - - - - - - - - - - - - - - - - - organizations: null, - - - - - - } - const [initialValues, setInitialValues] = useState(initVals) - const { units } = useAppSelector((state) => state.units) - - const { currentUser } = useAppSelector((state) => state.auth); - - + const [initialValues, setInitialValues] = useState(emptyInitialValues) const { id } = router.query useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) - - useEffect(() => { - if (typeof units === 'object') { - setInitialValues(units) + if (!router.isReady || typeof id !== 'string') { + return } - }, [units]) + + dispatch(fetch({ id })) + }, [dispatch, id, router.isReady]) useEffect(() => { - if (typeof units === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (units)[el]) - setInitialValues(newInitialVal); - } + if (!units || Array.isArray(units) || typeof units !== 'object') { + return + } + + setInitialValues({ + ...emptyInitialValues, + ...units, + }) }, [units]) - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + const handleSubmit = async (values: typeof emptyInitialValues) => { + if (typeof id !== 'string') { + return + } + + await dispatch(update({ id, data: values })) await router.push('/units/units-list') } @@ -332,625 +84,92 @@ const EditUnitsPage = () => { {getPageTitle('Edit Unit')} + - - {''} + + {''} - - handleSubmit(values)} - > -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - router.push('/units/units-list')}/> - - + + + {({ values }) => ( +
+ + +
+ Keep each unit tied to the right property and unit type. Availability blocks are handled later in the operating workflow, not here. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + + + + + + router.push('/units/units-list')} /> + + + )}
@@ -959,15 +178,7 @@ const EditUnitsPage = () => { } EditUnitsPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default EditUnitsPage diff --git a/frontend/src/pages/units/units-list.tsx b/frontend/src/pages/units/units-list.tsx index d75d3cd..8305458 100644 --- a/frontend/src/pages/units/units-list.tsx +++ b/frontend/src/pages/units/units-list.tsx @@ -14,9 +14,11 @@ import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,6 +34,7 @@ const UnitsTablesPage = () => { const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('units', currentUser); const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'}, @@ -78,6 +81,10 @@ const UnitsTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('units', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -111,6 +118,7 @@ const UnitsTablesPage = () => { onClick={addFilter} /> + {hasCreatePermission && ( { onConfirm={onModalConfirm} onCancel={onModalCancel} > + + { + const { values, setFieldValue } = useFormikContext() + const previousPropertyRef = useRef(values.property) + + useEffect(() => { + if (previousPropertyRef.current && previousPropertyRef.current !== values.property) { + setFieldValue('unit_type', null) + } + + previousPropertyRef.current = values.property + }, [setFieldValue, values.property]) + + return null +} const UnitsNew = () => { const router = useRouter() const dispatch = useAppDispatch() - - - - const handleSubmit = async (data) => { - await dispatch(create(data)) + const handleSubmit = async (values: typeof initialValues) => { + await dispatch(create(values)) await router.push('/units/units-list') } + return ( <> {getPageTitle('New Unit')} + - {''} + {''} - - handleSubmit(values)} - > -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - router.push('/units/units-list')}/> - - + + + {({ values }) => ( +
+ + +
+ Units are the real rentable inventory. Pick the property first, then choose one of that property's unit types. Availability blocks are managed later from operations screens. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + + + + + + router.push('/units/units-list')} /> + + + )}
@@ -532,15 +149,7 @@ const UnitsNew = () => { } UnitsNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default UnitsNew diff --git a/frontend/src/pages/units/units-table.tsx b/frontend/src/pages/units/units-table.tsx index 9a3e53d..5313770 100644 --- a/frontend/src/pages/units/units-table.tsx +++ b/frontend/src/pages/units/units-table.tsx @@ -14,9 +14,11 @@ import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; +import InventoryImportGuidance from '../../components/InventoryImportGuidance'; import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice'; +import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates'; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,6 +34,7 @@ const UnitsTablesPage = () => { const dispatch = useAppDispatch(); + const importTemplate = getInventoryImportTemplateConfig('units', currentUser); const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'}, @@ -78,6 +81,10 @@ const UnitsTablesPage = () => { link.click() }; + const downloadInventoryTemplate = async () => { + await downloadInventoryImportTemplate('units', importTemplate.fileName); + }; + const onModalConfirm = async () => { if (!csvFile) return; await dispatch(uploadCsv(csvFile)); @@ -111,6 +118,7 @@ const UnitsTablesPage = () => { onClick={addFilter} /> + {hasCreatePermission && ( { onConfirm={onModalConfirm} onCancel={onModalCancel} > + +