Autosave: 20260404-183711

This commit is contained in:
Flatlogic Bot 2026-04-04 18:37:11 +00:00
parent 716d1e45e3
commit eaaae3e8f7
32 changed files with 2306 additions and 5663 deletions

View File

@ -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,
};

View File

@ -1,10 +1,7 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const { resolveOrganizationIdsForTenant } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -74,8 +71,6 @@ module.exports = class OrganizationsDBApi {
static async update(id, data, options) { static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess;
const organizations = await db.organizations.findByPk(id, {}, {transaction}); const organizations = await db.organizations.findByPk(id, {}, {transaction});
@ -322,9 +317,6 @@ module.exports = class OrganizationsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -431,17 +423,30 @@ module.exports = class OrganizationsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) {
let where = {}; const filters = [];
const organizationIdsForTenant = tenantId
? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction)
: [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
where.organizationId = organizationId; filters.push({ id: organizationId });
} }
if (tenantId) {
if (!organizationIdsForTenant.length) {
return [];
}
filters.push({
id: {
[Op.in]: organizationIdsForTenant,
},
});
}
if (query) { if (query) {
where = { filters.push({
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -450,15 +455,19 @@ module.exports = class OrganizationsDBApi {
query, query,
), ),
], ],
}; });
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.organizations.findAll({ const records = await db.organizations.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']], order: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -2,8 +2,7 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const Utils = require('../utils'); const Utils = require('../utils');
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, prefixImportError, resolveOrganizationReference, resolveTenantIdForOrganization, resolveTenantReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -15,6 +14,17 @@ module.exports = class PropertiesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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( 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, transaction,
}); });
await properties.setOrganizations( data.organizations || null, { await properties.setOrganizations( organizationId || null, {
transaction, transaction,
}); });
@ -111,77 +121,90 @@ module.exports = class PropertiesDBApi {
static async bulkImport(data, options) { static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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 for (let index = 0; index < data.length; index += 1) {
const propertiesData = data.map((item, index) => ({ const item = normalizeInventoryImportRow(data[index]);
id: item.id || undefined, const rowNumber = index + 2;
name: item.name try {
|| const globalAccess = Boolean(currentUser.app_role?.globalAccess);
null let tenantId = null;
, let organizationId = null;
code: item.code if (globalAccess) {
|| tenantId = await resolveTenantReference(item.tenant, transaction);
null organizationId = await resolveOrganizationReference(item.organizations, {
, tenantId,
transaction,
});
address: item.address if (!organizationId) {
|| throw createBadRequestError('Organization is required for property import.');
null }
, } else {
organizationId = getCurrentOrganizationId(currentUser);
}
city: item.city if (!tenantId && organizationId) {
|| tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
null }
,
country: item.country item.tenant = tenantId || null;
|| item.organizations = organizationId || null;
null
,
timezone: item.timezone const itemId = item.id || null;
|| const importHash = item.importHash || null;
null if (options?.ignoreDuplicates) {
, if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
continue;
}
description: item.description const duplicateFilters = [];
|| if (itemId) {
null duplicateFilters.push({ id: itemId });
, }
if (importHash) {
duplicateFilters.push({ importHash });
}
is_active: item.is_active if (duplicateFilters.length) {
|| const existingRecord = await db.properties.findOne({
false where: {
[Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
, if (existingRecord) {
continue;
}
}
}
importHash: item.importHash || null, if (itemId) {
createdById: currentUser.id, seenIds.add(itemId);
updatedById: currentUser.id, }
createdAt: new Date(Date.now() + index * 1000), if (importHash) {
})); seenImportHashes.add(importHash);
}
// Bulk create items const createdRecord = await this.create(item, {
const properties = await db.properties.bulkCreate(propertiesData, { transaction }); ...options,
currentUser,
transaction,
});
// For each item created, replace relation files createdRecords.push(createdRecord);
} catch (error) {
for (let i = 0; i < properties.length; i++) { throw prefixImportError(error, rowNumber);
await FileDBApi.replaceRelationFiles( }
{
belongsTo: db.properties.getTableName(),
belongsToColumn: 'images',
belongsToId: properties[i].id,
},
data[i].images,
options,
);
} }
return createdRecords;
return properties;
} }
static async update(id, data, options) { 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 = []; const filters = [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ organizationsId: organizationId }); filters.push({ organizationsId: organizationId });
} }
if (tenantId) {
filters.push({ tenantId: Utils.uuid(tenantId) });
}
if (selectedOrganizationId) {
filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) });
}
if (query) { if (query) {
filters.push({ filters.push({
[Op.or]: [ [Op.or]: [
@ -786,7 +817,7 @@ module.exports = class PropertiesDBApi {
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']], order: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -1,8 +1,7 @@
const db = require('../models'); const db = require('../models');
const Utils = require('../utils'); const Utils = require('../utils');
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, prefixImportError, resolvePropertyReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -14,6 +13,17 @@ module.exports = class Unit_typesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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( const unit_types = await db.unit_types.create(
{ {
@ -81,7 +91,7 @@ module.exports = class Unit_typesDBApi {
transaction, transaction,
}); });
await unit_types.setOrganizations( data.organizations || null, { await unit_types.setOrganizations( organizationId || null, {
transaction, transaction,
}); });
@ -101,84 +111,98 @@ module.exports = class Unit_typesDBApi {
static async bulkImport(data, options) { static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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 for (let index = 0; index < data.length; index += 1) {
const unit_typesData = data.map((item, index) => ({ const item = normalizeInventoryImportRow(data[index]);
id: item.id || undefined, const rowNumber = index + 2;
name: item.name try {
|| const propertyId = await resolvePropertyReference(item.property, {
null currentUser,
, transaction,
});
code: item.code if (!propertyId) {
|| throw createBadRequestError('Property is required for unit type import.');
null }
,
max_occupancy: item.max_occupancy item.property = propertyId;
||
null
,
bedrooms: item.bedrooms const itemId = item.id || null;
|| const importHash = item.importHash || null;
null if (options?.ignoreDuplicates) {
, if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
continue;
}
bathrooms: item.bathrooms const duplicateFilters = [];
|| if (itemId) {
null duplicateFilters.push({ id: itemId });
, }
if (importHash) {
duplicateFilters.push({ importHash });
}
size_sqm: item.size_sqm if (duplicateFilters.length) {
|| const existingRecord = await db.unit_types.findOne({
null where: {
, [Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
description: item.description if (existingRecord) {
|| continue;
null }
, }
}
base_nightly_rate: item.base_nightly_rate if (itemId) {
|| seenIds.add(itemId);
null }
, if (importHash) {
seenImportHashes.add(importHash);
}
base_monthly_rate: item.base_monthly_rate const createdRecord = await this.create(item, {
|| ...options,
null currentUser,
, transaction,
});
minimum_stay_nights: item.minimum_stay_nights createdRecords.push(createdRecord);
|| } catch (error) {
null throw prefixImportError(error, rowNumber);
, }
}
importHash: item.importHash || null, return createdRecords;
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const unit_types = await db.unit_types.bulkCreate(unit_typesData, { transaction });
// For each item created, replace relation files
return unit_types;
} }
static async update(id, data, options) { static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess;
const unit_types = await db.unit_types.findByPk(id, {}, {transaction}); 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 = {}; const updatePayload = {};
@ -227,10 +251,10 @@ module.exports = class Unit_typesDBApi {
); );
} }
if (data.organizations !== undefined) { if (organizationId !== undefined) {
await unit_types.setOrganizations( await unit_types.setOrganizations(
data.organizations, organizationId,
{ transaction } { 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 = []; const filters = [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ organizationsId: organizationId }); filters.push({ organizationsId: organizationId });
} }
if (propertyId) {
filters.push({ propertyId: Utils.uuid(propertyId) });
}
if (query) { if (query) {
filters.push({ filters.push({
[Op.or]: [ [Op.or]: [
@ -792,7 +820,7 @@ module.exports = class Unit_typesDBApi {
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']], order: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -1,10 +1,7 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, loadUnitTypeContext, prefixImportError, resolvePropertyReference, resolveUnitTypeReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -16,6 +13,31 @@ module.exports = class UnitsDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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( 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, transaction,
}); });
@ -62,7 +84,7 @@ module.exports = class UnitsDBApi {
transaction, transaction,
}); });
await units.setOrganizations( data.organizations || null, { await units.setOrganizations( organizationId || null, {
transaction, transaction,
}); });
@ -82,49 +104,89 @@ module.exports = class UnitsDBApi {
static async bulkImport(data, options) { static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; 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 for (let index = 0; index < data.length; index += 1) {
const unitsData = data.map((item, index) => ({ const item = normalizeInventoryImportRow(data[index]);
id: item.id || undefined, const rowNumber = index + 2;
unit_number: item.unit_number try {
|| const propertyId = await resolvePropertyReference(item.property, {
null currentUser,
, transaction,
});
floor: item.floor if (!propertyId) {
|| throw createBadRequestError('Property is required for unit import.');
null }
,
status: item.status const property = await loadPropertyContext(propertyId, transaction);
|| const unitTypeId = await resolveUnitTypeReference(item.unit_type, {
null currentUser,
, organizationId: property?.organizationsId || null,
propertyId,
transaction,
});
max_occupancy_override: item.max_occupancy_override if (!unitTypeId) {
|| throw createBadRequestError('Unit type is required for unit import.');
null }
,
notes: item.notes item.property = propertyId;
|| item.unit_type = unitTypeId;
null
,
importHash: item.importHash || null, const itemId = item.id || null;
createdById: currentUser.id, const importHash = item.importHash || null;
updatedById: currentUser.id, if (options?.ignoreDuplicates) {
createdAt: new Date(Date.now() + index * 1000), if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
})); continue;
}
// Bulk create items const duplicateFilters = [];
const units = await db.units.bulkCreate(unitsData, { transaction }); if (itemId) {
duplicateFilters.push({ id: itemId });
}
if (importHash) {
duplicateFilters.push({ importHash });
}
// For each item created, replace relation files if (duplicateFilters.length) {
const existingRecord = await db.units.findOne({
where: {
[Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
if (existingRecord) {
continue;
}
}
}
return units; 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) { static async update(id, data, options) {
@ -133,9 +195,30 @@ module.exports = class UnitsDBApi {
const globalAccess = currentUser.app_role?.globalAccess; const globalAccess = currentUser.app_role?.globalAccess;
const units = await db.units.findByPk(id, {}, {transaction}); 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 = {}; 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( await units.setProperty(
data.property, propertyId,
{ transaction } { transaction }
); );
@ -178,10 +261,10 @@ module.exports = class UnitsDBApi {
); );
} }
if (data.organizations !== undefined) { if (organizationId !== undefined) {
await units.setOrganizations( await units.setOrganizations(
data.organizations, organizationId,
{ transaction } { transaction }
); );
@ -349,9 +432,6 @@ module.exports = class UnitsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -597,17 +677,23 @@ module.exports = class UnitsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) {
let where = {}; const filters = [];
if (!globalAccess && organizationId) { 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) { if (query) {
where = { filters.push({
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -616,15 +702,19 @@ module.exports = class UnitsDBApi {
query, query,
), ),
], ],
}; });
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.units.findAll({ const records = await db.units.findAll({
attributes: [ 'id', 'unit_number' ], attributes: [ 'id', 'unit_number' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['unit_number', 'ASC']], order: [['unit_number', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -5,8 +5,6 @@ const OrganizationsService = require('../services/organizations');
const OrganizationsDBApi = require('../db/api/organizations'); const OrganizationsDBApi = require('../db/api/organizations');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -377,17 +375,16 @@ router.get('/count', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => { router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const organizationId = req.currentUser.organization?.id
const payload = await OrganizationsDBApi.findAllAutocomplete( const payload = await OrganizationsDBApi.findAllAutocomplete(
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, organizationId, globalAccess,
organizationId,
req.query.tenantId || req.query.tenant,
); );
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
@ -17,7 +18,7 @@ const {
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; 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( const payload = await PropertiesDBApi.findAllAutocomplete(
req.query.query, req.query.query,
@ -25,6 +26,8 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re
req.query.offset, req.query.offset,
globalAccess, globalAccess,
organizationId, organizationId,
req.query.tenantId || req.query.tenant,
req.query.organizationsId || req.query.organizationId || req.query.organizations,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -150,6 +153,15 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * 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) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);

View File

@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
@ -17,7 +18,7 @@ const {
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; 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( const payload = await Unit_typesDBApi.findAllAutocomplete(
req.query.query, req.query.query,
@ -25,6 +26,7 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re
req.query.offset, req.query.offset,
globalAccess, globalAccess,
organizationId, organizationId,
req.query.propertyId || req.query.property,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -159,6 +161,15 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * 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) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);

View File

@ -5,12 +5,11 @@ const UnitsService = require('../services/units');
const UnitsDBApi = require('../db/api/units'); const UnitsDBApi = require('../db/api/units');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
@ -129,6 +128,15 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * 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) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);
@ -387,17 +395,17 @@ router.get('/count', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => { router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const organizationId = req.currentUser.organization?.id
const payload = await UnitsDBApi.findAllAutocomplete( const payload = await UnitsDBApi.findAllAutocomplete(
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, 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); res.status(200).send(payload);

View File

@ -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,
};

View File

@ -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,
};

View File

@ -1,11 +1,8 @@
const db = require('../db/models'); const db = require('../db/models');
const PropertiesDBApi = require('../db/api/properties'); const PropertiesDBApi = require('../db/api/properties');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const { parseCsvImportFile } = require('./importer');
const axios = require('axios'); const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
const config = require('../config');
const stream = require('stream');
@ -28,28 +25,17 @@ module.exports = class PropertiesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await processFile(req, res); const results = await parseCsvImportFile(
const bufferStream = new stream.PassThrough(); req,
const results = []; res,
getInventoryImportTemplate('properties', req.currentUser),
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));
})
await PropertiesDBApi.bulkImport(results, { await PropertiesDBApi.bulkImport(results, {
transaction, transaction,
@ -95,7 +81,7 @@ module.exports = class PropertiesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -132,7 +118,6 @@ module.exports = class PropertiesService {
} }
} }
}
};

View File

@ -1,11 +1,8 @@
const db = require('../db/models'); const db = require('../db/models');
const Unit_typesDBApi = require('../db/api/unit_types'); const Unit_typesDBApi = require('../db/api/unit_types');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const { parseCsvImportFile } = require('./importer');
const axios = require('axios'); const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
const config = require('../config');
const stream = require('stream');
@ -28,28 +25,17 @@ module.exports = class Unit_typesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await processFile(req, res); const results = await parseCsvImportFile(
const bufferStream = new stream.PassThrough(); req,
const results = []; res,
getInventoryImportTemplate('unit_types', req.currentUser),
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));
})
await Unit_typesDBApi.bulkImport(results, { await Unit_typesDBApi.bulkImport(results, {
transaction, transaction,
@ -95,7 +81,7 @@ module.exports = class Unit_typesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -132,7 +118,6 @@ module.exports = class Unit_typesService {
} }
} }
}
};

View File

@ -1,11 +1,8 @@
const db = require('../db/models'); const db = require('../db/models');
const UnitsDBApi = require('../db/api/units'); const UnitsDBApi = require('../db/api/units');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const { parseCsvImportFile } = require('./importer');
const axios = require('axios'); const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
const config = require('../config');
const stream = require('stream');
@ -28,28 +25,17 @@ module.exports = class UnitsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await processFile(req, res); const results = await parseCsvImportFile(
const bufferStream = new stream.PassThrough(); req,
const results = []; res,
getInventoryImportTemplate('units', req.currentUser),
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));
})
await UnitsDBApi.bulkImport(results, { await UnitsDBApi.bulkImport(results, {
transaction, transaction,
@ -95,7 +81,7 @@ module.exports = class UnitsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -132,7 +118,6 @@ module.exports = class UnitsService {
} }
} }
}
};

View File

@ -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 (
<div className='mb-4 rounded-2xl border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-600 dark:border-white/10 dark:text-slate-300'>
<p className='font-semibold'>Use the import template for this workflow</p>
<p className='mt-1'>{config.workflowHint}</p>
<p className='mt-1'>{config.referenceHint}</p>
<p className='mt-2'>
<span className='font-semibold'>Allowed columns:</span> {config.columns.join(', ')}
</p>
<p className='mt-1'>
<span className='font-semibold'>Required relationship columns:</span>{' '}
{config.requiredColumns.length ? config.requiredColumns.join(', ') : 'None'}
</p>
{config.exampleReferences.length > 0 && (
<div className='mt-3'>
<p className='font-semibold'>Reference examples</p>
<ul className='mt-2 space-y-1'>
{config.exampleReferences.map((example) => (
<li key={example.column} className='leading-5'>
<span className='font-semibold'>{example.column}:</span>{' '}
<code className='rounded bg-slate-100 px-1 py-0.5 text-xs text-slate-800 dark:bg-slate-900 dark:text-slate-100'>
{example.value}
</code>
{example.note ? <span className='ml-1'>{example.note}</span> : null}
</li>
))}
</ul>
</div>
)}
<div className='mt-3'>
<p className='font-semibold'>Example row</p>
<p className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
Replace these example values with real records from your workspace before uploading.
</p>
<pre className='mt-2 overflow-x-auto rounded-xl bg-slate-100 p-3 text-xs leading-5 text-slate-800 dark:bg-slate-900 dark:text-slate-100'>
{`${exampleCsvHeader}
${exampleCsvRow}`}
</pre>
</div>
</div>
);
};
export default InventoryImportGuidance;

View File

@ -28,7 +28,7 @@ const buildQueryString = (itemRef, queryParams) => {
return params.toString(); 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 [value, setValue] = useState(null)
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]); const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]);
@ -58,6 +58,18 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
setValue(option) 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[]) { async function callApi(inputValue: string, loadedOptions: any[]) {
const params = new URLSearchParams(extraQueryString); const params = new URLSearchParams(extraQueryString);
params.set('limit', String(PAGE_SIZE)); params.set('limit', String(PAGE_SIZE));
@ -88,6 +100,8 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
defaultOptions defaultOptions
isDisabled={disabled} isDisabled={disabled}
isClearable isClearable
placeholder={placeholder || 'Select...'}
noOptionsMessage={getNoOptionsMessage}
/> />
) )
} }

View File

@ -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}`);
}
};

View File

@ -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 || ''
}

View File

@ -168,11 +168,27 @@ const EditBooking_requestsPage = () => {
</FormField> </FormField>
<FormField label='Preferred property' labelFor='preferred_property'> <FormField label='Preferred property' labelFor='preferred_property'>
<Field name='preferred_property' id='preferred_property' component={SelectField} options={initialValues.preferred_property} itemRef={'properties'} /> <Field
name='preferred_property'
id='preferred_property'
component={SelectField}
options={initialValues.preferred_property}
itemRef={'properties'}
placeholder='Select a property'
noOptionsMessage='No properties available yet'
/>
</FormField> </FormField>
<FormField label='Preferred unit type' labelFor='preferred_unit_type'> <FormField label='Preferred unit type' labelFor='preferred_unit_type'>
<Field name='preferred_unit_type' id='preferred_unit_type' component={SelectField} options={initialValues.preferred_unit_type} itemRef={'unit_types'} /> <Field
name='preferred_unit_type'
id='preferred_unit_type'
component={SelectField}
options={initialValues.preferred_unit_type}
itemRef={'unit_types'}
placeholder='Select a unit type'
noOptionsMessage='No unit types available yet'
/>
</FormField> </FormField>
<FormField label='Preferred bedrooms'> <FormField label='Preferred bedrooms'>

View File

@ -126,11 +126,27 @@ const Booking_requestsNew = () => {
</FormField> </FormField>
<FormField label='Preferred property' labelFor='preferred_property'> <FormField label='Preferred property' labelFor='preferred_property'>
<Field name='preferred_property' id='preferred_property' component={SelectField} options={[]} itemRef={'properties'} /> <Field
name='preferred_property'
id='preferred_property'
component={SelectField}
options={[]}
itemRef={'properties'}
placeholder='Select a property'
noOptionsMessage='No properties available yet'
/>
</FormField> </FormField>
<FormField label='Preferred unit type' labelFor='preferred_unit_type'> <FormField label='Preferred unit type' labelFor='preferred_unit_type'>
<Field name='preferred_unit_type' id='preferred_unit_type' component={SelectField} options={[]} itemRef={'unit_types'} /> <Field
name='preferred_unit_type'
id='preferred_unit_type'
component={SelectField}
options={[]}
itemRef={'unit_types'}
placeholder='Select a unit type'
noOptionsMessage='No unit types available yet'
/>
</FormField> </FormField>
<FormField label='Preferred bedrooms'> <FormField label='Preferred bedrooms'>

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,11 @@ import axios from "axios";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice'; import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { humanize } from "../../helpers/humanize"; import { humanize } from "../../helpers/humanize";
@ -29,6 +31,7 @@ const PropertiesTablesPage = () => {
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); 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'}, 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'}, {label: 'Tenant', title: 'tenant'},
@ -61,6 +64,10 @@ const PropertiesTablesPage = () => {
link.click() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('properties', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -106,6 +113,7 @@ const PropertiesTablesPage = () => {
) : null} ) : null}
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} /> <BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getPropertiesCSV} /> <BaseButton color='whiteDark' outline label='Export CSV' onClick={getPropertiesCSV} />
<BaseButton color='whiteDark' outline label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission ? ( {hasCreatePermission ? (
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} /> <BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
) : null} ) : null}
@ -136,6 +144,8 @@ const PropertiesTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}

View File

@ -1,785 +1,199 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect, useRef } 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 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 SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' 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 { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField' import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany"; import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField"; import { getPageTitle } from '../../config'
import { canManageInventoryWorkspace, getCurrentOrganizationOption, getRelationId } from '../../helpers/inventoryWorkspace'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { create } from '../../stores/properties/propertiesSlice' import { create } from '../../stores/properties/propertiesSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = { const initialValues = {
tenant: null,
name: '',
code: '',
address: '',
city: '',
country: '',
timezone: '',
description: '',
images: [],
is_active: false,
organizations: null,
tenant: '',
name: '',
code: '',
address: '',
city: '',
country: '',
timezone: '',
description: '',
images: [],
is_active: false,
unit_types: [],
units: [],
amenities: [],
organizations: '',
} }
const PropertyWorkspaceSync = () => {
const { values, setFieldValue } = useFormikContext<any>()
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 PropertiesNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth)
const canManageWorkspace = canManageInventoryWorkspace(currentUser)
const currentOrganization = getCurrentOrganizationOption(currentUser)
const handleSubmit = async (values: typeof initialValues) => {
const payload: Record<string, any> = { ...values }
if (!canManageWorkspace) {
delete payload.tenant
payload.organizations = currentOrganization?.id || null
}
const handleSubmit = async (data) => { await dispatch(create(payload))
await dispatch(create(data))
await router.push('/properties/properties-list') await router.push('/properties/properties-list')
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Property')}</title> <title>{getPageTitle('New Property')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Property" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Property" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mx-auto max-w-6xl'>
<Formik
initialValues={
initialValues <CardBox className="mx-auto max-w-6xl">
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
} {({ values }) => (
onSubmit={(values) => handleSubmit(values)} <Form>
> <PropertyWorkspaceSync />
<Form>
<div className='grid gap-5 md:grid-cols-2'> <div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-4 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
Create the property first. Add unit types, units, and amenities after the property exists so the setup follows the real customer workflow.
</div>
{!canManageWorkspace ? (
<div className="mb-6 rounded-2xl border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500 dark:border-white/10 dark:text-slate-400">
This property will be saved inside your current workspace{currentOrganization ? ` (${currentOrganization.name})` : ''}. Tenant and organization are assigned automatically.
</div>
) : null}
<div className="grid gap-5 md:grid-cols-2">
{canManageWorkspace ? (
<>
<FormField label="Tenant" labelFor="tenant" help="Choose the customer account that owns this property.">
<Field
name="tenant"
id="tenant"
component={SelectField}
options={[]}
itemRef={'tenants'}
placeholder="Select a tenant"
noOptionsMessage="No tenants available yet"
<FormField label="Tenant" labelFor="tenant"> />
<Field name="tenant" id="tenant" component={SelectField} options={[]} itemRef={'tenants'}></Field> </FormField>
</FormField>
<FormField
label="Organization"
labelFor="organizations"
help="Choose the workspace within the selected tenant that will manage this property."
>
<Field
name="organizations"
id="organizations"
component={SelectField}
<FormField options={[]}
label="Propertyname" itemRef={'organizations'}
> queryParams={getRelationId(values.tenant) ? { tenantId: getRelationId(values.tenant) } : undefined}
<Field disabled={!getRelationId(values.tenant)}
name="name" placeholder={getRelationId(values.tenant) ? 'Select an organization' : 'Select a tenant first'}
placeholder="Propertyname" noOptionsMessage={
/> getRelationId(values.tenant) ? 'No organizations are linked to this tenant yet' : 'Select a tenant first'
</FormField> }
/>
</FormField>
</>
) : null}
<FormField label="Property name" labelFor="name">
<Field name="name" id="name" placeholder="Downtown Tower" />
</FormField>
<FormField label="Property code" labelFor="code">
<Field name="code" id="code" placeholder="DT-001" />
</FormField>
<FormField label="Address" labelFor="address" hasTextareaHeight>
<Field name="address" id="address" as="textarea" placeholder="Street address" />
</FormField>
<FormField label="City" labelFor="city">
<Field name="city" id="city" placeholder="City" />
</FormField>
<FormField label="Country" labelFor="country">
<Field name="country" id="country" placeholder="Country" />
</FormField>
<FormField label="Timezone" labelFor="timezone">
<Field name="timezone" id="timezone" placeholder="America/New_York" />
<FormField </FormField>
label="Propertycode"
> <div className="md:col-span-2">
<Field <FormField label="Description" labelFor="description" hasTextareaHeight>
name="code" <Field name="description" id="description" component={RichTextField} />
placeholder="Propertycode" </FormField>
/> </div>
</FormField>
<FormField label="Images" labelFor="images">
<Field
label="Images"
color="info"
icon={mdiUpload}
path={'properties/images'}
name="images"
id="images"
component={FormImagePicker}
schema={{
size: undefined,
formats: undefined,
}}
/>
</FormField>
<FormField label="Active property" labelFor="is_active">
<Field name="is_active" id="is_active" component={SwitchField} />
</FormField>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Save" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type="reset" color="danger" outline label="Cancel" onClick={() => router.push('/properties/properties-list')} />
</BaseButtons>
</Form>
<FormField label="Address" hasTextareaHeight> )}
<Field name="address" as="textarea" placeholder="Address" />
</FormField>
<FormField
label="City"
>
<Field
name="city"
placeholder="City"
/>
</FormField>
<FormField
label="Country"
>
<Field
name="country"
placeholder="Country"
/>
</FormField>
<FormField
label="Timezone"
>
<Field
name="timezone"
placeholder="Timezone"
/>
</FormField>
<FormField label='Description' hasTextareaHeight>
<Field
name='description'
id='description'
component={RichTextField}
></Field>
</FormField>
<FormField>
<Field
label='Images'
color='info'
icon={mdiUpload}
path={'properties/images'}
name='images'
id='images'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label='Isactive' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
></Field>
</FormField>
<FormField label='Unittypes' labelFor='unit_types'>
<Field
name='unit_types'
id='unit_types'
itemRef={'unit_types'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='Units' labelFor='units'>
<Field
name='units'
id='units'
itemRef={'units'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='Amenities' labelFor='amenities'>
<Field
name='amenities'
id='amenities'
itemRef={'amenities'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label="organizations" labelFor="organizations">
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
</FormField>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Save" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/properties/properties-list')}/>
</BaseButtons>
</Form>
</Formik> </Formik>
</CardBox> </CardBox>
</SectionMain> </SectionMain>
@ -788,15 +202,7 @@ const PropertiesNew = () => {
} }
PropertiesNew.getLayout = function getLayout(page: ReactElement) { PropertiesNew.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated>{page}</LayoutAuthenticated>
<LayoutAuthenticated
permission={'CREATE_PROPERTIES'}
>
{page}
</LayoutAuthenticated>
)
} }
export default PropertiesNew export default PropertiesNew

View File

@ -14,9 +14,11 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice'; import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -32,6 +34,7 @@ const PropertiesTablesPage = () => {
const dispatch = useAppDispatch(); 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'}, 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() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('properties', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -107,6 +114,7 @@ const PropertiesTablesPage = () => {
onClick={addFilter} onClick={addFilter}
/> />
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPropertiesCSV} /> <BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPropertiesCSV} />
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton
@ -143,6 +151,8 @@ const PropertiesTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,11 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice'; import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser);
const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'}, const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'},
@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => {
link.click() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('unit_types', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => {
onClick={addFilter} onClick={addFilter}
/> />
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} /> <BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} />
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton
@ -141,6 +149,8 @@ const Unit_typesTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}

View File

@ -1,733 +1,126 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { Field, Form, Formik } 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 CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import FormField from '../../components/FormField'
import { RichTextField } from '../../components/RichTextField'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' 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 { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField' import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany"; import { getPageTitle } from '../../config'
import {RichTextField} from "../../components/RichTextField"; import LayoutAuthenticated from '../../layouts/Authenticated'
import { create } from '../../stores/unit_types/unit_typesSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router' import { create } from '../../stores/unit_types/unit_typesSlice'
import moment from 'moment';
const initialValues = { const initialValues = {
property: null,
name: '',
code: '',
max_occupancy: '',
bedrooms: '',
bathrooms: '',
size_sqm: '',
description: '',
base_nightly_rate: '',
base_monthly_rate: '',
minimum_stay_nights: '',
property: '',
name: '',
code: '',
max_occupancy: '',
bedrooms: '',
bathrooms: '',
size_sqm: '',
description: '',
base_nightly_rate: '',
base_monthly_rate: '',
minimum_stay_nights: '',
units: [],
organizations: '',
} }
const UnitTypesNew = () => {
const Unit_typesNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const handleSubmit = async (values: typeof initialValues) => {
await dispatch(create(values))
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/unit_types/unit_types-list') await router.push('/unit_types/unit_types-list')
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('New Unit Type')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit Type" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues <CardBox className="mx-auto max-w-6xl">
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
}
onSubmit={(values) => handleSubmit(values)}
>
<Form> <Form>
<div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-4 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
Unit types are templates under a property, like Studio or Two Bedroom Deluxe. Create the actual units after this template is saved.
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Property" labelFor="property" help="Choose the property this unit type belongs to.">
<Field
name="property"
id="property"
component={SelectField}
options={[]}
itemRef={'properties'}
placeholder="Select a property"
noOptionsMessage="No properties available yet"
/>
</FormField>
<FormField label="Unit type name" labelFor="name">
<Field name="name" id="name" placeholder="Studio" />
</FormField>
<FormField label="Property" labelFor="property"> <FormField label="Code" labelFor="code">
<Field name="property" id="property" component={SelectField} options={[]} itemRef={'properties'}></Field> <Field name="code" id="code" placeholder="STUDIO" />
</FormField> </FormField>
<FormField label="Max occupancy" labelFor="max_occupancy">
<Field type="number" name="max_occupancy" id="max_occupancy" placeholder="2" />
</FormField>
<FormField label="Bedrooms" labelFor="bedrooms">
<Field type="number" name="bedrooms" id="bedrooms" placeholder="1" />
</FormField>
<FormField <FormField label="Bathrooms" labelFor="bathrooms">
label="Unittypename" <Field type="number" name="bathrooms" id="bathrooms" placeholder="1" />
> </FormField>
<Field
name="name" <FormField label="Size (sqm)" labelFor="size_sqm">
placeholder="Unittypename" <Field type="number" name="size_sqm" id="size_sqm" placeholder="45" />
/> </FormField>
</FormField>
<FormField label="Base nightly rate" labelFor="base_nightly_rate">
<Field type="number" name="base_nightly_rate" id="base_nightly_rate" placeholder="150" />
</FormField>
<FormField label="Base monthly rate" labelFor="base_monthly_rate">
<Field type="number" name="base_monthly_rate" id="base_monthly_rate" placeholder="3000" />
</FormField>
<FormField label="Minimum stay nights" labelFor="minimum_stay_nights">
<Field type="number" name="minimum_stay_nights" id="minimum_stay_nights" placeholder="2" />
</FormField>
<div className="md:col-span-2">
<FormField label="Description" labelFor="description" hasTextareaHeight>
<Field name="description" id="description" component={RichTextField} />
</FormField>
</div>
</div>
<FormField
label="Code"
>
<Field
name="code"
placeholder="Code"
/>
</FormField>
<FormField
label="Maxoccupancy"
>
<Field
type="number"
name="max_occupancy"
placeholder="Maxoccupancy"
/>
</FormField>
<FormField
label="Bedrooms"
>
<Field
type="number"
name="bedrooms"
placeholder="Bedrooms"
/>
</FormField>
<FormField
label="Bathrooms"
>
<Field
type="number"
name="bathrooms"
placeholder="Bathrooms"
/>
</FormField>
<FormField
label="Size(sqm)"
>
<Field
type="number"
name="size_sqm"
placeholder="Size(sqm)"
/>
</FormField>
<FormField label='Description' hasTextareaHeight>
<Field
name='description'
id='description'
component={RichTextField}
></Field>
</FormField>
<FormField
label="Basenightlyrate"
>
<Field
type="number"
name="base_nightly_rate"
placeholder="Basenightlyrate"
/>
</FormField>
<FormField
label="Basemonthlyrate"
>
<Field
type="number"
name="base_monthly_rate"
placeholder="Basemonthlyrate"
/>
</FormField>
<FormField
label="Minimumstay(nights)"
>
<Field
type="number"
name="minimum_stay_nights"
placeholder="Minimumstay(nights)"
/>
</FormField>
<FormField label='Units' labelFor='units'>
<Field
name='units'
id='units'
itemRef={'units'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label="organizations" labelFor="organizations">
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
</FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type="submit" color="info" label="Submit" /> <BaseButton type="submit" color="info" label="Save" />
<BaseButton type="reset" color="info" outline label="Reset" /> <BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/unit_types/unit_types-list')}/> <BaseButton type="reset" color="danger" outline label="Cancel" onClick={() => router.push('/unit_types/unit_types-list')} />
</BaseButtons> </BaseButtons>
</Form> </Form>
</Formik> </Formik>
@ -737,16 +130,8 @@ const Unit_typesNew = () => {
) )
} }
Unit_typesNew.getLayout = function getLayout(page: ReactElement) { UnitTypesNew.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated>{page}</LayoutAuthenticated>
<LayoutAuthenticated
permission={'CREATE_UNIT_TYPES'}
>
{page}
</LayoutAuthenticated>
)
} }
export default Unit_typesNew export default UnitTypesNew

View File

@ -14,9 +14,11 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice'; import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser);
const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'}, const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'},
@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => {
link.click() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('unit_types', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => {
onClick={addFilter} onClick={addFilter}
/> />
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} /> <BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} />
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton
@ -143,6 +151,8 @@ const Unit_typesTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,11 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice'; import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -32,6 +34,7 @@ const UnitsTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const importTemplate = getInventoryImportTemplateConfig('units', currentUser);
const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'}, const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'},
@ -78,6 +81,10 @@ const UnitsTablesPage = () => {
link.click() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('units', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -111,6 +118,7 @@ const UnitsTablesPage = () => {
onClick={addFilter} onClick={addFilter}
/> />
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} /> <BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} />
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton
@ -145,6 +153,8 @@ const UnitsTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}

View File

@ -1,529 +1,146 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect, useRef } 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 CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import FormField from '../../components/FormField'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' 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 { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField' import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany"; import { getPageTitle } from '../../config'
import {RichTextField} from "../../components/RichTextField"; import { getRelationId } from '../../helpers/inventoryWorkspace'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { create } from '../../stores/units/unitsSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router' import { create } from '../../stores/units/unitsSlice'
import moment from 'moment';
const initialValues = { const initialValues = {
property: null,
unit_type: null,
unit_number: '',
floor: '',
status: 'available',
max_occupancy_override: '',
notes: '',
property: '',
unit_type: '',
unit_number: '',
floor: '',
status: 'available',
max_occupancy_override: '',
notes: '',
availability_blocks: [],
organizations: '',
} }
const UnitPropertySync = () => {
const { values, setFieldValue } = useFormikContext<any>()
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 UnitsNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const handleSubmit = async (values: typeof initialValues) => {
await dispatch(create(values))
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/units/units-list') await router.push('/units/units-list')
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Unit')}</title> <title>{getPageTitle('New Unit')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mx-auto max-w-6xl'>
<Formik
initialValues={
initialValues <CardBox className="mx-auto max-w-6xl">
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
} {({ values }) => (
onSubmit={(values) => handleSubmit(values)} <Form>
> <UnitPropertySync />
<Form>
<div className='grid gap-5 md:grid-cols-2'> <div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-4 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
Units are the real rentable inventory. Pick the property first, then choose one of that property&apos;s unit types. Availability blocks are managed later from operations screens.
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Property" labelFor="property" help="Choose where this unit belongs.">
<Field
name="property"
id="property"
component={SelectField}
options={[]}
itemRef={'properties'}
placeholder="Select a property"
noOptionsMessage="No properties available yet"
/>
</FormField>
<FormField label="Unit type" labelFor="unit_type" help="Only unit types from the selected property are shown.">
<Field
name="unit_type"
id="unit_type"
component={SelectField}
<FormField label="Property" labelFor="property"> options={[]}
<Field name="property" id="property" component={SelectField} options={[]} itemRef={'properties'}></Field> itemRef={'unit_types'}
</FormField> queryParams={getRelationId(values.property) ? { property: getRelationId(values.property) } : undefined}
disabled={!getRelationId(values.property)}
placeholder={getRelationId(values.property) ? 'Select a unit type' : 'Select a property first'}
noOptionsMessage={
getRelationId(values.property) ? 'No unit types available for this property yet' : 'Select a property first'
}
/>
</FormField>
<FormField label="Unit number" labelFor="unit_number">
<Field name="unit_number" id="unit_number" placeholder="1203" />
</FormField>
<FormField label="Floor" labelFor="floor">
<Field name="floor" id="floor" placeholder="12" />
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
<option value="available">available</option>
<option value="occupied">occupied</option>
<option value="maintenance">maintenance</option>
<option value="cleaning_hold">cleaning_hold</option>
<option value="reserved">reserved</option>
<option value="out_of_service">out_of_service</option>
</Field>
</FormField>
<FormField label="Unittype" labelFor="unit_type"> <FormField label="Max occupancy override" labelFor="max_occupancy_override">
<Field name="unit_type" id="unit_type" component={SelectField} options={[]} itemRef={'unit_types'}></Field> <Field type="number" name="max_occupancy_override" id="max_occupancy_override" placeholder="2" />
</FormField> </FormField>
<div className="md:col-span-2">
<FormField label="Notes" labelFor="notes" hasTextareaHeight>
<Field name="notes" id="notes" as="textarea" placeholder="Operational notes" />
</FormField>
</div>
</div>
<BaseDivider />
<FormField
label="Unitnumber" <BaseButtons>
> <BaseButton type="submit" color="info" label="Save" />
<Field <BaseButton type="reset" color="info" outline label="Reset" />
name="unit_number" <BaseButton type="reset" color="danger" outline label="Cancel" onClick={() => router.push('/units/units-list')} />
placeholder="Unitnumber" </BaseButtons>
/> </Form>
</FormField> )}
<FormField
label="Floor"
>
<Field
name="floor"
placeholder="Floor"
/>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
<option value="available">available</option>
<option value="occupied">occupied</option>
<option value="maintenance">maintenance</option>
<option value="cleaning_hold">cleaning_hold</option>
<option value="reserved">reserved</option>
<option value="out_of_service">out_of_service</option>
</Field>
</FormField>
<FormField
label="Maxoccupancyoverride"
>
<Field
type="number"
name="max_occupancy_override"
placeholder="Maxoccupancyoverride"
/>
</FormField>
<FormField label="Notes" hasTextareaHeight>
<Field name="notes" as="textarea" placeholder="Notes" />
</FormField>
<FormField label='Availabilityblocks' labelFor='availability_blocks'>
<Field
name='availability_blocks'
id='availability_blocks'
itemRef={'unit_availability_blocks'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label="organizations" labelFor="organizations">
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
</FormField>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Save" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/units/units-list')}/>
</BaseButtons>
</Form>
</Formik> </Formik>
</CardBox> </CardBox>
</SectionMain> </SectionMain>
@ -532,15 +149,7 @@ const UnitsNew = () => {
} }
UnitsNew.getLayout = function getLayout(page: ReactElement) { UnitsNew.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated>{page}</LayoutAuthenticated>
<LayoutAuthenticated
permission={'CREATE_UNITS'}
>
{page}
</LayoutAuthenticated>
)
} }
export default UnitsNew export default UnitsNew

View File

@ -14,9 +14,11 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice'; import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice';
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -32,6 +34,7 @@ const UnitsTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const importTemplate = getInventoryImportTemplateConfig('units', currentUser);
const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'}, const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'},
@ -78,6 +81,10 @@ const UnitsTablesPage = () => {
link.click() link.click()
}; };
const downloadInventoryTemplate = async () => {
await downloadInventoryImportTemplate('units', importTemplate.fileName);
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile));
@ -111,6 +118,7 @@ const UnitsTablesPage = () => {
onClick={addFilter} onClick={addFilter}
/> />
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} /> <BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} />
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton
@ -147,6 +155,8 @@ const UnitsTablesPage = () => {
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<InventoryImportGuidance config={importTemplate} />
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
<DragDropFilePicker <DragDropFilePicker
file={csvFile} file={csvFile}
setFile={setCsvFile} setFile={setCsvFile}