Autosave: 20260404-183711
This commit is contained in:
parent
716d1e45e3
commit
eaaae3e8f7
398
backend/src/db/api/inventoryContext.js
Normal file
398
backend/src/db/api/inventoryContext.js
Normal 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,
|
||||
};
|
||||
@ -1,10 +1,7 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
const { resolveOrganizationIdsForTenant } = require('./inventoryContext');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
@ -74,8 +71,6 @@ module.exports = class OrganizationsDBApi {
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
const organizations = await db.organizations.findByPk(id, {}, {transaction});
|
||||
|
||||
|
||||
@ -322,9 +317,6 @@ module.exports = class OrganizationsDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
@ -431,17 +423,30 @@ module.exports = class OrganizationsDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||
let where = {};
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) {
|
||||
const filters = [];
|
||||
const organizationIdsForTenant = tenantId
|
||||
? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction)
|
||||
: [];
|
||||
|
||||
if (!globalAccess && organizationId) {
|
||||
where.organizationId = organizationId;
|
||||
filters.push({ id: organizationId });
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
if (!organizationIdsForTenant.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
filters.push({
|
||||
id: {
|
||||
[Op.in]: organizationIdsForTenant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
filters.push({
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
@ -450,15 +455,19 @@ module.exports = class OrganizationsDBApi {
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const where = filters.length > 1
|
||||
? { [Op.and]: filters }
|
||||
: (filters[0] || {});
|
||||
|
||||
const records = await db.organizations.findAll({
|
||||
attributes: [ 'id', 'name' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['name', 'ASC']],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, prefixImportError, resolveOrganizationReference, resolveTenantIdForOrganization, resolveTenantReference } = require('./inventoryContext');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
@ -15,6 +14,17 @@ module.exports = class PropertiesDBApi {
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
let organizationId = data.organizations || null;
|
||||
if (!organizationId && !globalAccess) {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
let tenantId = data.tenant || null;
|
||||
if (!tenantId && organizationId) {
|
||||
tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
|
||||
}
|
||||
|
||||
const properties = await db.properties.create(
|
||||
{
|
||||
@ -69,11 +79,11 @@ module.exports = class PropertiesDBApi {
|
||||
);
|
||||
|
||||
|
||||
await properties.setTenant( data.tenant || null, {
|
||||
await properties.setTenant( tenantId || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await properties.setOrganizations( data.organizations || null, {
|
||||
await properties.setOrganizations( organizationId || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -111,77 +121,90 @@ module.exports = class PropertiesDBApi {
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const seenIds = new Set();
|
||||
const seenImportHashes = new Set();
|
||||
const createdRecords = [];
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const propertiesData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const item = normalizeInventoryImportRow(data[index]);
|
||||
const rowNumber = index + 2;
|
||||
|
||||
name: item.name
|
||||
||
|
||||
null
|
||||
,
|
||||
try {
|
||||
const globalAccess = Boolean(currentUser.app_role?.globalAccess);
|
||||
let tenantId = null;
|
||||
let organizationId = null;
|
||||
|
||||
code: item.code
|
||||
||
|
||||
null
|
||||
,
|
||||
if (globalAccess) {
|
||||
tenantId = await resolveTenantReference(item.tenant, transaction);
|
||||
organizationId = await resolveOrganizationReference(item.organizations, {
|
||||
tenantId,
|
||||
transaction,
|
||||
});
|
||||
|
||||
address: item.address
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
city: item.city
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
country: item.country
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
timezone: item.timezone
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
description: item.description
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
is_active: item.is_active
|
||||
||
|
||||
false
|
||||
|
||||
,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
|
||||
// Bulk create items
|
||||
const properties = await db.properties.bulkCreate(propertiesData, { transaction });
|
||||
|
||||
// For each item created, replace relation files
|
||||
|
||||
for (let i = 0; i < properties.length; i++) {
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.properties.getTableName(),
|
||||
belongsToColumn: 'images',
|
||||
belongsToId: properties[i].id,
|
||||
},
|
||||
data[i].images,
|
||||
options,
|
||||
);
|
||||
if (!organizationId) {
|
||||
throw createBadRequestError('Organization is required for property import.');
|
||||
}
|
||||
} else {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
if (!tenantId && organizationId) {
|
||||
tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
|
||||
}
|
||||
|
||||
return properties;
|
||||
item.tenant = tenantId || null;
|
||||
item.organizations = organizationId || null;
|
||||
|
||||
const itemId = item.id || null;
|
||||
const importHash = item.importHash || null;
|
||||
if (options?.ignoreDuplicates) {
|
||||
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duplicateFilters = [];
|
||||
if (itemId) {
|
||||
duplicateFilters.push({ id: itemId });
|
||||
}
|
||||
if (importHash) {
|
||||
duplicateFilters.push({ importHash });
|
||||
}
|
||||
|
||||
if (duplicateFilters.length) {
|
||||
const existingRecord = await db.properties.findOne({
|
||||
where: {
|
||||
[Op.or]: duplicateFilters,
|
||||
},
|
||||
transaction,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
if (existingRecord) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemId) {
|
||||
seenIds.add(itemId);
|
||||
}
|
||||
if (importHash) {
|
||||
seenImportHashes.add(importHash);
|
||||
}
|
||||
|
||||
const createdRecord = await this.create(item, {
|
||||
...options,
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
createdRecords.push(createdRecord);
|
||||
} catch (error) {
|
||||
throw prefixImportError(error, rowNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return createdRecords;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
@ -757,13 +780,21 @@ module.exports = class PropertiesDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, selectedOrganizationId) {
|
||||
const filters = [];
|
||||
|
||||
if (!globalAccess && organizationId) {
|
||||
filters.push({ organizationsId: organizationId });
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
filters.push({ tenantId: Utils.uuid(tenantId) });
|
||||
}
|
||||
|
||||
if (selectedOrganizationId) {
|
||||
filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) });
|
||||
}
|
||||
|
||||
if (query) {
|
||||
filters.push({
|
||||
[Op.or]: [
|
||||
@ -786,7 +817,7 @@ module.exports = class PropertiesDBApi {
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['name', 'ASC']],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, prefixImportError, resolvePropertyReference } = require('./inventoryContext');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
@ -14,6 +13,17 @@ module.exports = class Unit_typesDBApi {
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
if (!data.property) {
|
||||
throw createBadRequestError('Select a property before creating a unit type.');
|
||||
}
|
||||
|
||||
const property = await loadPropertyContext(data.property || null, transaction);
|
||||
|
||||
let organizationId = data.organizations || property?.organizationsId || null;
|
||||
if (!organizationId && !globalAccess) {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
const unit_types = await db.unit_types.create(
|
||||
{
|
||||
@ -81,7 +91,7 @@ module.exports = class Unit_typesDBApi {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await unit_types.setOrganizations( data.organizations || null, {
|
||||
await unit_types.setOrganizations( organizationId || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -101,84 +111,98 @@ module.exports = class Unit_typesDBApi {
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const seenIds = new Set();
|
||||
const seenImportHashes = new Set();
|
||||
const createdRecords = [];
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const unit_typesData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const item = normalizeInventoryImportRow(data[index]);
|
||||
const rowNumber = index + 2;
|
||||
|
||||
name: item.name
|
||||
||
|
||||
null
|
||||
,
|
||||
try {
|
||||
const propertyId = await resolvePropertyReference(item.property, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
code: item.code
|
||||
||
|
||||
null
|
||||
,
|
||||
if (!propertyId) {
|
||||
throw createBadRequestError('Property is required for unit type import.');
|
||||
}
|
||||
|
||||
max_occupancy: item.max_occupancy
|
||||
||
|
||||
null
|
||||
,
|
||||
item.property = propertyId;
|
||||
|
||||
bedrooms: item.bedrooms
|
||||
||
|
||||
null
|
||||
,
|
||||
const itemId = item.id || null;
|
||||
const importHash = item.importHash || null;
|
||||
if (options?.ignoreDuplicates) {
|
||||
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bathrooms: item.bathrooms
|
||||
||
|
||||
null
|
||||
,
|
||||
const duplicateFilters = [];
|
||||
if (itemId) {
|
||||
duplicateFilters.push({ id: itemId });
|
||||
}
|
||||
if (importHash) {
|
||||
duplicateFilters.push({ importHash });
|
||||
}
|
||||
|
||||
size_sqm: item.size_sqm
|
||||
||
|
||||
null
|
||||
,
|
||||
if (duplicateFilters.length) {
|
||||
const existingRecord = await db.unit_types.findOne({
|
||||
where: {
|
||||
[Op.or]: duplicateFilters,
|
||||
},
|
||||
transaction,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
description: item.description
|
||||
||
|
||||
null
|
||||
,
|
||||
if (existingRecord) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base_nightly_rate: item.base_nightly_rate
|
||||
||
|
||||
null
|
||||
,
|
||||
if (itemId) {
|
||||
seenIds.add(itemId);
|
||||
}
|
||||
if (importHash) {
|
||||
seenImportHashes.add(importHash);
|
||||
}
|
||||
|
||||
base_monthly_rate: item.base_monthly_rate
|
||||
||
|
||||
null
|
||||
,
|
||||
const createdRecord = await this.create(item, {
|
||||
...options,
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
minimum_stay_nights: item.minimum_stay_nights
|
||||
||
|
||||
null
|
||||
,
|
||||
createdRecords.push(createdRecord);
|
||||
} catch (error) {
|
||||
throw prefixImportError(error, rowNumber);
|
||||
}
|
||||
}
|
||||
|
||||
importHash: item.importHash || null,
|
||||
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;
|
||||
return createdRecords;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
const unit_types = await db.unit_types.findByPk(id, {}, {transaction});
|
||||
const nextPropertyId = data.property !== undefined ? data.property : unit_types?.propertyId;
|
||||
if (!nextPropertyId) {
|
||||
throw createBadRequestError('Select a property before saving this unit type.');
|
||||
}
|
||||
|
||||
const property = await loadPropertyContext(nextPropertyId || null, transaction);
|
||||
|
||||
|
||||
let organizationId = data.organizations;
|
||||
if (organizationId === undefined && data.property !== undefined) {
|
||||
organizationId = property?.organizationsId || null;
|
||||
}
|
||||
if ((organizationId === undefined || organizationId === null) && !globalAccess && !unit_types?.organizationsId) {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
@ -227,10 +251,10 @@ module.exports = class Unit_typesDBApi {
|
||||
);
|
||||
}
|
||||
|
||||
if (data.organizations !== undefined) {
|
||||
if (organizationId !== undefined) {
|
||||
await unit_types.setOrganizations(
|
||||
|
||||
data.organizations,
|
||||
organizationId,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
@ -763,13 +787,17 @@ module.exports = class Unit_typesDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId) {
|
||||
const filters = [];
|
||||
|
||||
if (!globalAccess && organizationId) {
|
||||
filters.push({ organizationsId: organizationId });
|
||||
}
|
||||
|
||||
if (propertyId) {
|
||||
filters.push({ propertyId: Utils.uuid(propertyId) });
|
||||
}
|
||||
|
||||
if (query) {
|
||||
filters.push({
|
||||
[Op.or]: [
|
||||
@ -792,7 +820,7 @@ module.exports = class Unit_typesDBApi {
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['name', 'ASC']],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, loadUnitTypeContext, prefixImportError, resolvePropertyReference, resolveUnitTypeReference } = require('./inventoryContext');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
@ -16,6 +13,31 @@ module.exports = class UnitsDBApi {
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
let propertyId = data.property || null;
|
||||
const unitType = await loadUnitTypeContext(data.unit_type || null, transaction);
|
||||
if (!propertyId && unitType?.propertyId) {
|
||||
propertyId = unitType.propertyId;
|
||||
}
|
||||
|
||||
if (!propertyId) {
|
||||
throw createBadRequestError('Select a property before creating a unit.');
|
||||
}
|
||||
if (!data.unit_type) {
|
||||
throw createBadRequestError('Select a unit type before creating a unit.');
|
||||
}
|
||||
|
||||
const property = await loadPropertyContext(propertyId || null, transaction);
|
||||
|
||||
if (property && unitType?.propertyId && unitType.propertyId !== property.id) {
|
||||
throw createBadRequestError('Selected unit type does not belong to the selected property.');
|
||||
}
|
||||
|
||||
let organizationId = data.organizations || property?.organizationsId || unitType?.organizationsId || null;
|
||||
if (!organizationId && !globalAccess) {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
const units = await db.units.create(
|
||||
{
|
||||
@ -54,7 +76,7 @@ module.exports = class UnitsDBApi {
|
||||
);
|
||||
|
||||
|
||||
await units.setProperty( data.property || null, {
|
||||
await units.setProperty( propertyId || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -62,7 +84,7 @@ module.exports = class UnitsDBApi {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await units.setOrganizations( data.organizations || null, {
|
||||
await units.setOrganizations( organizationId || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -82,49 +104,89 @@ module.exports = class UnitsDBApi {
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const seenIds = new Set();
|
||||
const seenImportHashes = new Set();
|
||||
const createdRecords = [];
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const unitsData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const item = normalizeInventoryImportRow(data[index]);
|
||||
const rowNumber = index + 2;
|
||||
|
||||
unit_number: item.unit_number
|
||||
||
|
||||
null
|
||||
,
|
||||
try {
|
||||
const propertyId = await resolvePropertyReference(item.property, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
floor: item.floor
|
||||
||
|
||||
null
|
||||
,
|
||||
if (!propertyId) {
|
||||
throw createBadRequestError('Property is required for unit import.');
|
||||
}
|
||||
|
||||
status: item.status
|
||||
||
|
||||
null
|
||||
,
|
||||
const property = await loadPropertyContext(propertyId, transaction);
|
||||
const unitTypeId = await resolveUnitTypeReference(item.unit_type, {
|
||||
currentUser,
|
||||
organizationId: property?.organizationsId || null,
|
||||
propertyId,
|
||||
transaction,
|
||||
});
|
||||
|
||||
max_occupancy_override: item.max_occupancy_override
|
||||
||
|
||||
null
|
||||
,
|
||||
if (!unitTypeId) {
|
||||
throw createBadRequestError('Unit type is required for unit import.');
|
||||
}
|
||||
|
||||
notes: item.notes
|
||||
||
|
||||
null
|
||||
,
|
||||
item.property = propertyId;
|
||||
item.unit_type = unitTypeId;
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
const itemId = item.id || null;
|
||||
const importHash = item.importHash || null;
|
||||
if (options?.ignoreDuplicates) {
|
||||
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bulk create items
|
||||
const units = await db.units.bulkCreate(unitsData, { transaction });
|
||||
const duplicateFilters = [];
|
||||
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) {
|
||||
@ -133,9 +195,30 @@ module.exports = class UnitsDBApi {
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
const units = await db.units.findByPk(id, {}, {transaction});
|
||||
const nextPropertyId = data.property !== undefined ? data.property : units?.propertyId;
|
||||
const nextUnitTypeId = data.unit_type !== undefined ? data.unit_type : units?.unit_typeId;
|
||||
const unitType = await loadUnitTypeContext(nextUnitTypeId || null, transaction);
|
||||
const propertyId = nextPropertyId || unitType?.propertyId || null;
|
||||
if (!propertyId) {
|
||||
throw createBadRequestError('Select a property before saving this unit.');
|
||||
}
|
||||
if (!nextUnitTypeId) {
|
||||
throw createBadRequestError('Select a unit type before saving this unit.');
|
||||
}
|
||||
|
||||
const property = await loadPropertyContext(propertyId || null, transaction);
|
||||
|
||||
if (property && unitType?.propertyId && unitType.propertyId !== property.id) {
|
||||
throw createBadRequestError('Selected unit type does not belong to the selected property.');
|
||||
}
|
||||
|
||||
let organizationId = data.organizations;
|
||||
if (organizationId === undefined && (data.property !== undefined || data.unit_type !== undefined)) {
|
||||
organizationId = property?.organizationsId || unitType?.organizationsId || null;
|
||||
}
|
||||
if ((organizationId === undefined || organizationId === null) && !globalAccess && !units?.organizationsId) {
|
||||
organizationId = getCurrentOrganizationId(currentUser);
|
||||
}
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
@ -160,10 +243,10 @@ module.exports = class UnitsDBApi {
|
||||
|
||||
|
||||
|
||||
if (data.property !== undefined) {
|
||||
if (data.property !== undefined || (data.unit_type !== undefined && propertyId !== units?.propertyId)) {
|
||||
await units.setProperty(
|
||||
|
||||
data.property,
|
||||
propertyId,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
@ -178,10 +261,10 @@ module.exports = class UnitsDBApi {
|
||||
);
|
||||
}
|
||||
|
||||
if (data.organizations !== undefined) {
|
||||
if (organizationId !== undefined) {
|
||||
await units.setOrganizations(
|
||||
|
||||
data.organizations,
|
||||
organizationId,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
@ -349,9 +432,6 @@ module.exports = class UnitsDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
@ -597,17 +677,23 @@ module.exports = class UnitsDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||
let where = {};
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) {
|
||||
const filters = [];
|
||||
|
||||
if (!globalAccess && organizationId) {
|
||||
where.organizationId = organizationId;
|
||||
filters.push({ organizationsId: organizationId });
|
||||
}
|
||||
|
||||
if (propertyId) {
|
||||
filters.push({ propertyId: Utils.uuid(propertyId) });
|
||||
}
|
||||
|
||||
if (unitTypeId) {
|
||||
filters.push({ unit_typeId: Utils.uuid(unitTypeId) });
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
filters.push({
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
@ -616,15 +702,19 @@ module.exports = class UnitsDBApi {
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const where = filters.length > 1
|
||||
? { [Op.and]: filters }
|
||||
: (filters[0] || {});
|
||||
|
||||
const records = await db.units.findAll({
|
||||
attributes: [ 'id', 'unit_number' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['unit_number', 'ASC']],
|
||||
order: [['unit_number', 'ASC']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
|
||||
@ -5,8 +5,6 @@ const OrganizationsService = require('../services/organizations');
|
||||
const OrganizationsDBApi = require('../db/api/organizations');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -377,17 +375,16 @@ router.get('/count', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/autocomplete', async (req, res) => {
|
||||
|
||||
const globalAccess = req.currentUser.app_role.globalAccess;
|
||||
|
||||
const organizationId = req.currentUser.organization?.id
|
||||
|
||||
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
|
||||
|
||||
const payload = await OrganizationsDBApi.findAllAutocomplete(
|
||||
req.query.query,
|
||||
req.query.limit,
|
||||
req.query.offset,
|
||||
globalAccess, organizationId,
|
||||
globalAccess,
|
||||
organizationId,
|
||||
req.query.tenantId || req.query.tenant,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
|
||||
@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync;
|
||||
const router = express.Router();
|
||||
|
||||
const { parse } = require('json2csv');
|
||||
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
|
||||
|
||||
|
||||
const {
|
||||
@ -17,7 +18,7 @@ const {
|
||||
|
||||
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
|
||||
const globalAccess = req.currentUser.app_role.globalAccess;
|
||||
const organizationId = req.currentUser.organization?.id;
|
||||
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
|
||||
|
||||
const payload = await PropertiesDBApi.findAllAutocomplete(
|
||||
req.query.query,
|
||||
@ -25,6 +26,8 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re
|
||||
req.query.offset,
|
||||
globalAccess,
|
||||
organizationId,
|
||||
req.query.tenantId || req.query.tenant,
|
||||
req.query.organizationsId || req.query.organizationId || req.query.organizations,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
@ -150,6 +153,15 @@ router.post('/', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*
|
||||
*/
|
||||
router.get('/import-template', wrapAsync(async (req, res) => {
|
||||
const template = getInventoryImportTemplate('properties', req.currentUser);
|
||||
const csv = buildInventoryImportTemplateCsv('properties', req.currentUser);
|
||||
|
||||
res.status(200);
|
||||
res.attachment(template.fileName);
|
||||
res.send(csv);
|
||||
}));
|
||||
|
||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
|
||||
@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync;
|
||||
const router = express.Router();
|
||||
|
||||
const { parse } = require('json2csv');
|
||||
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
|
||||
|
||||
|
||||
const {
|
||||
@ -17,7 +18,7 @@ const {
|
||||
|
||||
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
|
||||
const globalAccess = req.currentUser.app_role.globalAccess;
|
||||
const organizationId = req.currentUser.organization?.id;
|
||||
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
|
||||
|
||||
const payload = await Unit_typesDBApi.findAllAutocomplete(
|
||||
req.query.query,
|
||||
@ -25,6 +26,7 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re
|
||||
req.query.offset,
|
||||
globalAccess,
|
||||
organizationId,
|
||||
req.query.propertyId || req.query.property,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
@ -159,6 +161,15 @@ router.post('/', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*
|
||||
*/
|
||||
router.get('/import-template', wrapAsync(async (req, res) => {
|
||||
const template = getInventoryImportTemplate('unit_types', req.currentUser);
|
||||
const csv = buildInventoryImportTemplateCsv('unit_types', req.currentUser);
|
||||
|
||||
res.status(200);
|
||||
res.attachment(template.fileName);
|
||||
res.send(csv);
|
||||
}));
|
||||
|
||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
|
||||
@ -5,12 +5,11 @@ const UnitsService = require('../services/units');
|
||||
const UnitsDBApi = require('../db/api/units');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const { parse } = require('json2csv');
|
||||
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
|
||||
|
||||
|
||||
const {
|
||||
@ -129,6 +128,15 @@ router.post('/', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*
|
||||
*/
|
||||
router.get('/import-template', wrapAsync(async (req, res) => {
|
||||
const template = getInventoryImportTemplate('units', req.currentUser);
|
||||
const csv = buildInventoryImportTemplateCsv('units', req.currentUser);
|
||||
|
||||
res.status(200);
|
||||
res.attachment(template.fileName);
|
||||
res.send(csv);
|
||||
}));
|
||||
|
||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
@ -387,17 +395,17 @@ router.get('/count', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/autocomplete', async (req, res) => {
|
||||
|
||||
const globalAccess = req.currentUser.app_role.globalAccess;
|
||||
|
||||
const organizationId = req.currentUser.organization?.id
|
||||
|
||||
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
|
||||
|
||||
const payload = await UnitsDBApi.findAllAutocomplete(
|
||||
req.query.query,
|
||||
req.query.limit,
|
||||
req.query.offset,
|
||||
globalAccess, organizationId,
|
||||
globalAccess,
|
||||
organizationId,
|
||||
req.query.propertyId || req.query.property,
|
||||
req.query.unitTypeId || req.query.unit_type || req.query.unitType,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
|
||||
152
backend/src/services/importer.js
Normal file
152
backend/src/services/importer.js
Normal 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,
|
||||
};
|
||||
72
backend/src/services/inventoryImportTemplates.js
Normal file
72
backend/src/services/inventoryImportTemplates.js
Normal 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,
|
||||
};
|
||||
@ -1,11 +1,8 @@
|
||||
const db = require('../db/models');
|
||||
const PropertiesDBApi = require('../db/api/properties');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const { parseCsvImportFile } = require('./importer');
|
||||
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||
|
||||
|
||||
|
||||
@ -28,28 +25,17 @@ module.exports = class PropertiesService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await processFile(req, res);
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
const results = await parseCsvImportFile(
|
||||
req,
|
||||
res,
|
||||
getInventoryImportTemplate('properties', req.currentUser),
|
||||
);
|
||||
|
||||
await PropertiesDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
@ -95,7 +81,7 @@ module.exports = class PropertiesService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -132,7 +118,6 @@ module.exports = class PropertiesService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
const db = require('../db/models');
|
||||
const Unit_typesDBApi = require('../db/api/unit_types');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const { parseCsvImportFile } = require('./importer');
|
||||
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||
|
||||
|
||||
|
||||
@ -28,28 +25,17 @@ module.exports = class Unit_typesService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await processFile(req, res);
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
const results = await parseCsvImportFile(
|
||||
req,
|
||||
res,
|
||||
getInventoryImportTemplate('unit_types', req.currentUser),
|
||||
);
|
||||
|
||||
await Unit_typesDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
@ -95,7 +81,7 @@ module.exports = class Unit_typesService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -132,7 +118,6 @@ module.exports = class Unit_typesService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
const db = require('../db/models');
|
||||
const UnitsDBApi = require('../db/api/units');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const { parseCsvImportFile } = require('./importer');
|
||||
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||
|
||||
|
||||
|
||||
@ -28,28 +25,17 @@ module.exports = class UnitsService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await processFile(req, res);
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
const results = await parseCsvImportFile(
|
||||
req,
|
||||
res,
|
||||
getInventoryImportTemplate('units', req.currentUser),
|
||||
);
|
||||
|
||||
await UnitsDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
@ -95,7 +81,7 @@ module.exports = class UnitsService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -132,7 +118,6 @@ module.exports = class UnitsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
57
frontend/src/components/InventoryImportGuidance.tsx
Normal file
57
frontend/src/components/InventoryImportGuidance.tsx
Normal 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;
|
||||
@ -28,7 +28,7 @@ const buildQueryString = (itemRef, queryParams) => {
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams }) => {
|
||||
export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams, placeholder, noOptionsMessage }) => {
|
||||
const [value, setValue] = useState(null)
|
||||
const PAGE_SIZE = 100;
|
||||
const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]);
|
||||
@ -58,6 +58,18 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
setValue(option)
|
||||
}
|
||||
|
||||
const getNoOptionsMessage = ({ inputValue }: { inputValue: string }) => {
|
||||
if (typeof noOptionsMessage === 'function') {
|
||||
return noOptionsMessage({ inputValue, itemRef })
|
||||
}
|
||||
|
||||
if (typeof noOptionsMessage === 'string') {
|
||||
return inputValue ? `No matching results for "${inputValue}"` : noOptionsMessage
|
||||
}
|
||||
|
||||
return inputValue ? `No matching results for "${inputValue}"` : 'No options available yet'
|
||||
}
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||
const params = new URLSearchParams(extraQueryString);
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
@ -88,6 +100,8 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
defaultOptions
|
||||
isDisabled={disabled}
|
||||
isClearable
|
||||
placeholder={placeholder || 'Select...'}
|
||||
noOptionsMessage={getNoOptionsMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
186
frontend/src/helpers/inventoryImportTemplates.ts
Normal file
186
frontend/src/helpers/inventoryImportTemplates.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
36
frontend/src/helpers/inventoryWorkspace.ts
Normal file
36
frontend/src/helpers/inventoryWorkspace.ts
Normal 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 || ''
|
||||
}
|
||||
@ -168,11 +168,27 @@ const EditBooking_requestsPage = () => {
|
||||
</FormField>
|
||||
|
||||
<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 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 label='Preferred bedrooms'>
|
||||
|
||||
@ -126,11 +126,27 @@ const Booking_requestsNew = () => {
|
||||
</FormField>
|
||||
|
||||
<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 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 label='Preferred bedrooms'>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,9 +14,11 @@ import axios from "axios";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
|
||||
@ -29,6 +31,7 @@ const PropertiesTablesPage = () => {
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('properties', currentUser);
|
||||
|
||||
const [filters] = useState(([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'},
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
@ -61,6 +64,10 @@ const PropertiesTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('properties', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -106,6 +113,7 @@ const PropertiesTablesPage = () => {
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getPropertiesCSV} />
|
||||
<BaseButton color='whiteDark' outline label='Import template' onClick={downloadInventoryTemplate} />
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
|
||||
) : null}
|
||||
@ -136,6 +144,8 @@ const PropertiesTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
@ -1,785 +1,199 @@
|
||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
|
||||
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 LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import FormField from '../../components/FormField'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import { RichTextField } from '../../components/RichTextField'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import FormField from '../../components/FormField'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
||||
import FormFilePicker from '../../components/FormFilePicker'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
|
||||
import { SelectField } from '../../components/SelectField'
|
||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||
import {RichTextField} from "../../components/RichTextField";
|
||||
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
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 { useAppDispatch } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
|
||||
const initialValues = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
tenant: '',
|
||||
|
||||
|
||||
|
||||
|
||||
tenant: null,
|
||||
name: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
code: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
address: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
city: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
country: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
timezone: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
description: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
images: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
is_active: false,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
unit_types: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
units: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
amenities: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
organizations: '',
|
||||
|
||||
|
||||
|
||||
organizations: null,
|
||||
}
|
||||
|
||||
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 router = useRouter()
|
||||
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(data))
|
||||
await dispatch(create(payload))
|
||||
await router.push('/properties/properties-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Property')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Property" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<CardBox className="mx-auto max-w-6xl">
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ values }) => (
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
<PropertyWorkspaceSync />
|
||||
|
||||
<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}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Tenant" labelFor="tenant">
|
||||
<Field name="tenant" id="tenant" component={SelectField} options={[]} itemRef={'tenants'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Propertyname"
|
||||
>
|
||||
<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="name"
|
||||
placeholder="Propertyname"
|
||||
name="tenant"
|
||||
id="tenant"
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef={'tenants'}
|
||||
placeholder="Select a tenant"
|
||||
noOptionsMessage="No tenants available yet"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Propertycode"
|
||||
label="Organization"
|
||||
labelFor="organizations"
|
||||
help="Choose the workspace within the selected tenant that will manage this property."
|
||||
>
|
||||
<Field
|
||||
name="code"
|
||||
placeholder="Propertycode"
|
||||
name="organizations"
|
||||
id="organizations"
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef={'organizations'}
|
||||
queryParams={getRelationId(values.tenant) ? { tenantId: getRelationId(values.tenant) } : undefined}
|
||||
disabled={!getRelationId(values.tenant)}
|
||||
placeholder={getRelationId(values.tenant) ? 'Select an organization' : 'Select a tenant first'}
|
||||
noOptionsMessage={
|
||||
getRelationId(values.tenant) ? 'No organizations are linked to this tenant yet' : 'Select a tenant first'
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Address" hasTextareaHeight>
|
||||
<Field name="address" as="textarea" placeholder="Address" />
|
||||
<FormField label="Property name" labelFor="name">
|
||||
<Field name="name" id="name" placeholder="Downtown Tower" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="City"
|
||||
>
|
||||
<Field
|
||||
name="city"
|
||||
placeholder="City"
|
||||
/>
|
||||
<FormField label="Property code" labelFor="code">
|
||||
<Field name="code" id="code" placeholder="DT-001" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Country"
|
||||
>
|
||||
<Field
|
||||
name="country"
|
||||
placeholder="Country"
|
||||
/>
|
||||
<FormField label="Address" labelFor="address" hasTextareaHeight>
|
||||
<Field name="address" id="address" as="textarea" placeholder="Street address" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Timezone"
|
||||
>
|
||||
<Field
|
||||
name="timezone"
|
||||
placeholder="Timezone"
|
||||
/>
|
||||
<FormField label="City" labelFor="city">
|
||||
<Field name="city" id="city" placeholder="City" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Description' hasTextareaHeight>
|
||||
<Field
|
||||
name='description'
|
||||
id='description'
|
||||
component={RichTextField}
|
||||
></Field>
|
||||
<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>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<FormField label="Description" labelFor="description" hasTextareaHeight>
|
||||
<Field name="description" id="description" component={RichTextField} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField>
|
||||
<FormField label="Images" labelFor="images">
|
||||
<Field
|
||||
label='Images'
|
||||
color='info'
|
||||
label="Images"
|
||||
color="info"
|
||||
icon={mdiUpload}
|
||||
path={'properties/images'}
|
||||
name='images'
|
||||
id='images'
|
||||
name="images"
|
||||
id="images"
|
||||
component={FormImagePicker}
|
||||
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 label="Active property" labelFor="is_active">
|
||||
<Field name="is_active" id="is_active" component={SwitchField} />
|
||||
</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')}/>
|
||||
<BaseButton type="reset" color="danger" outline label="Cancel" onClick={() => router.push('/properties/properties-list')} />
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
@ -788,15 +202,7 @@ const PropertiesNew = () => {
|
||||
}
|
||||
|
||||
PropertiesNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_PROPERTIES'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default PropertiesNew
|
||||
|
||||
@ -14,9 +14,11 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ const PropertiesTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('properties', currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'},
|
||||
@ -74,6 +77,10 @@ const PropertiesTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('properties', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -107,6 +114,7 @@ const PropertiesTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPropertiesCSV} />
|
||||
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
@ -143,6 +151,8 @@ const PropertiesTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,9 +14,11 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'},
|
||||
@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('unit_types', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} />
|
||||
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
@ -141,6 +149,8 @@ const Unit_typesTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
@ -1,733 +1,126 @@
|
||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
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 LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import FormField from '../../components/FormField'
|
||||
import { RichTextField } from '../../components/RichTextField'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import FormField from '../../components/FormField'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
||||
import FormFilePicker from '../../components/FormFilePicker'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
|
||||
import { SelectField } from '../../components/SelectField'
|
||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||
import {RichTextField} from "../../components/RichTextField";
|
||||
|
||||
import { create } from '../../stores/unit_types/unit_typesSlice'
|
||||
import { getPageTitle } from '../../config'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
import { create } from '../../stores/unit_types/unit_typesSlice'
|
||||
|
||||
const initialValues = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
property: '',
|
||||
|
||||
|
||||
|
||||
|
||||
property: null,
|
||||
name: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
code: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
max_occupancy: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bedrooms: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bathrooms: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
size_sqm: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
description: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
base_nightly_rate: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
base_monthly_rate: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
minimum_stay_nights: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
units: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
organizations: '',
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
const Unit_typesNew = () => {
|
||||
const UnitTypesNew = () => {
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(create(data))
|
||||
const handleSubmit = async (values: typeof initialValues) => {
|
||||
await dispatch(create(values))
|
||||
await router.push('/unit_types/unit_types-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Unit Type')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit Type" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<CardBox className="mx-auto max-w-6xl">
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Property" labelFor="property">
|
||||
<Field name="property" id="property" component={SelectField} options={[]} itemRef={'properties'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Unittypename"
|
||||
>
|
||||
<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="name"
|
||||
placeholder="Unittypename"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<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'}
|
||||
name="property"
|
||||
id="property"
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
itemRef={'properties'}
|
||||
placeholder="Select a property"
|
||||
noOptionsMessage="No properties available yet"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="organizations" labelFor="organizations">
|
||||
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
|
||||
<FormField label="Unit type name" labelFor="name">
|
||||
<Field name="name" id="name" placeholder="Studio" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Code" labelFor="code">
|
||||
<Field name="code" id="code" placeholder="STUDIO" />
|
||||
</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 label="Bathrooms" labelFor="bathrooms">
|
||||
<Field type="number" name="bathrooms" id="bathrooms" placeholder="1" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Size (sqm)" labelFor="size_sqm">
|
||||
<Field type="number" name="size_sqm" id="size_sqm" placeholder="45" />
|
||||
</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>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<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='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>
|
||||
</Form>
|
||||
</Formik>
|
||||
@ -737,16 +130,8 @@ const Unit_typesNew = () => {
|
||||
)
|
||||
}
|
||||
|
||||
Unit_typesNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_UNIT_TYPES'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
UnitTypesNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Unit_typesNew
|
||||
export default UnitTypesNew
|
||||
|
||||
@ -14,9 +14,11 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/unit_types/unit_typesSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ const Unit_typesTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('unit_types', currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Unittypename', title: 'name'},{label: 'Code', title: 'code'},{label: 'Description', title: 'description'},
|
||||
@ -74,6 +77,10 @@ const Unit_typesTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('unit_types', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -107,6 +114,7 @@ const Unit_typesTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnit_typesCSV} />
|
||||
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
@ -143,6 +151,8 @@ const Unit_typesTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,9 +14,11 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ const UnitsTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('units', currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'},
|
||||
@ -78,6 +81,10 @@ const UnitsTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('units', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -111,6 +118,7 @@ const UnitsTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} />
|
||||
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
@ -145,6 +153,8 @@ const UnitsTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
@ -1,529 +1,146 @@
|
||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
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 LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import FormField from '../../components/FormField'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import FormField from '../../components/FormField'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
||||
import FormFilePicker from '../../components/FormFilePicker'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
|
||||
import { SelectField } from '../../components/SelectField'
|
||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||
import {RichTextField} from "../../components/RichTextField";
|
||||
|
||||
import { create } from '../../stores/units/unitsSlice'
|
||||
import { getPageTitle } from '../../config'
|
||||
import { getRelationId } from '../../helpers/inventoryWorkspace'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
import { create } from '../../stores/units/unitsSlice'
|
||||
|
||||
const initialValues = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
property: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
unit_type: '',
|
||||
|
||||
|
||||
|
||||
|
||||
property: null,
|
||||
unit_type: null,
|
||||
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 router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(create(data))
|
||||
const handleSubmit = async (values: typeof initialValues) => {
|
||||
await dispatch(create(values))
|
||||
await router.push('/units/units-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Unit')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<CardBox className="mx-auto max-w-6xl">
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ values }) => (
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
<UnitPropertySync />
|
||||
|
||||
<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's unit types. Availability blocks are managed later from operations screens.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Property" labelFor="property">
|
||||
<Field name="property" id="property" component={SelectField} options={[]} itemRef={'properties'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Unittype" labelFor="unit_type">
|
||||
<Field name="unit_type" id="unit_type" component={SelectField} options={[]} itemRef={'unit_types'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Unitnumber"
|
||||
>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<FormField label="Property" labelFor="property" help="Choose where this unit belongs.">
|
||||
<Field
|
||||
name="unit_number"
|
||||
placeholder="Unitnumber"
|
||||
name="property"
|
||||
id="property"
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef={'properties'}
|
||||
placeholder="Select a property"
|
||||
noOptionsMessage="No properties available yet"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Floor"
|
||||
>
|
||||
<FormField label="Unit type" labelFor="unit_type" help="Only unit types from the selected property are shown.">
|
||||
<Field
|
||||
name="floor"
|
||||
placeholder="Floor"
|
||||
name="unit_type"
|
||||
id="unit_type"
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef={'unit_types'}
|
||||
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="Maxoccupancyoverride"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="max_occupancy_override"
|
||||
placeholder="Maxoccupancyoverride"
|
||||
/>
|
||||
<FormField label="Max occupancy override" labelFor="max_occupancy_override">
|
||||
<Field type="number" name="max_occupancy_override" id="max_occupancy_override" placeholder="2" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Notes" hasTextareaHeight>
|
||||
<Field name="notes" as="textarea" placeholder="Notes" />
|
||||
<div className="md:col-span-2">
|
||||
<FormField label="Notes" labelFor="notes" hasTextareaHeight>
|
||||
<Field name="notes" id="notes" as="textarea" placeholder="Operational 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>
|
||||
</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')}/>
|
||||
<BaseButton type="reset" color="danger" outline label="Cancel" onClick={() => router.push('/units/units-list')} />
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
@ -532,15 +149,7 @@ const UnitsNew = () => {
|
||||
}
|
||||
|
||||
UnitsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_UNITS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default UnitsNew
|
||||
|
||||
@ -14,9 +14,11 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import InventoryImportGuidance from '../../components/InventoryImportGuidance';
|
||||
import {setRefetch, uploadCsv} from '../../stores/units/unitsSlice';
|
||||
|
||||
|
||||
import { downloadInventoryImportTemplate, getInventoryImportTemplateConfig } from '../../helpers/inventoryImportTemplates';
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ const UnitsTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const importTemplate = getInventoryImportTemplateConfig('units', currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Unitnumber', title: 'unit_number'},{label: 'Floor', title: 'floor'},{label: 'Notes', title: 'notes'},
|
||||
@ -78,6 +81,10 @@ const UnitsTablesPage = () => {
|
||||
link.click()
|
||||
};
|
||||
|
||||
const downloadInventoryTemplate = async () => {
|
||||
await downloadInventoryImportTemplate('units', importTemplate.fileName);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
@ -111,6 +118,7 @@ const UnitsTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUnitsCSV} />
|
||||
<BaseButton className={'mr-3'} color='info' label='Import template' onClick={downloadInventoryTemplate} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
@ -147,6 +155,8 @@ const UnitsTablesPage = () => {
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<InventoryImportGuidance config={importTemplate} />
|
||||
<BaseButton className={'mb-4'} color='info' outline label='Download template' onClick={downloadInventoryTemplate} />
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user