diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/assets/pasted-20260329-051144-5bfe6900.png b/assets/pasted-20260329-051144-5bfe6900.png new file mode 100644 index 0000000..c278ee2 Binary files /dev/null and b/assets/pasted-20260329-051144-5bfe6900.png differ diff --git a/backend/src/db/api/quartiers.js b/backend/src/db/api/quartiers.js new file mode 100644 index 0000000..667b02b --- /dev/null +++ b/backend/src/db/api/quartiers.js @@ -0,0 +1,211 @@ +const db = require('../models'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class QuartiersDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const record = await db.quartiers.create( + { + id: data.id || undefined, + nom: data.nom || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await record.setVillage(data.village || data.villageId || null, { transaction }); + + return record; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const rows = data.map((item, index) => ({ + id: item.id || undefined, + nom: item.nom || null, + notes: item.notes || null, + villageId: item.villageId || item.village || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + return db.quartiers.bulkCreate(rows, { transaction }); + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const record = await db.quartiers.findByPk(id, { transaction }); + + const payload = { + updatedById: currentUser.id, + }; + + if (data.nom !== undefined) payload.nom = data.nom; + if (data.notes !== undefined) payload.notes = data.notes; + + await record.update(payload, { transaction }); + + if (data.village !== undefined || data.villageId !== undefined) { + await record.setVillage(data.village || data.villageId || null, { transaction }); + } + + return record; + } + + static async deleteByIds(ids, options) { + const transaction = (options && options.transaction) || undefined; + + const records = await db.quartiers.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + for (const record of records) { + await record.destroy({ transaction }); + } + + return records; + } + + static async remove(id, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.quartiers.findByPk(id, { transaction }); + + await record.destroy({ transaction }); + + return record; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.quartiers.findOne({ + where, + include: [ + { + model: db.villages, + as: 'village', + attributes: ['id', 'nom'], + }, + ], + transaction, + }); + + if (!record) { + return record; + } + + return record.get({ plain: true }); + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + const currentPage = +filter.page || 0; + const offset = currentPage * limit; + let where = {}; + + if (filter.id) { + where.id = Utils.uuid(filter.id); + } + + if (filter.nom) { + where = { + ...where, + [Op.and]: Utils.ilike('quartiers', 'nom', filter.nom), + }; + } + + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('quartiers', 'notes', filter.notes), + }; + } + + if (filter.villageId || filter.village) { + where.villageId = filter.villageId || filter.village; + } + + const include = [ + { + model: db.villages, + as: 'village', + attributes: ['id', 'nom'], + required: false, + }, + ]; + + const queryOptions = { + where, + include, + distinct: true, + order: filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['nom', 'asc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + const { rows, count } = await db.quartiers.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count, + }; + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike('quartiers', 'nom', query), + ], + }; + } + + const records = await db.quartiers.findAll({ + attributes: ['id', 'nom'], + include: [ + { + model: db.villages, + as: 'village', + attributes: ['nom'], + required: false, + }, + ], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['nom', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.village?.nom ? `${record.nom} (${record.village.nom})` : record.nom, + })); + } +}; diff --git a/backend/src/db/api/type_usages.js b/backend/src/db/api/type_usages.js new file mode 100644 index 0000000..ddcf76b --- /dev/null +++ b/backend/src/db/api/type_usages.js @@ -0,0 +1,207 @@ +const db = require('../models'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class Type_usagesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const type_usages = await db.type_usages.create( + { + id: data.id || undefined, + nom: data.nom || null, + tarif: data.tarif ?? 0, + actif: data.actif ?? true, + description: data.description || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return type_usages; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const rows = data.map((item, index) => ({ + id: item.id || undefined, + nom: item.nom || null, + tarif: item.tarif ?? 0, + actif: item.actif === undefined ? true : item.actif, + description: item.description || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + return db.type_usages.bulkCreate(rows, { transaction }); + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const type_usages = await db.type_usages.findByPk(id, { transaction }); + + const payload = { + updatedById: currentUser.id, + }; + + if (data.nom !== undefined) payload.nom = data.nom; + if (data.tarif !== undefined) payload.tarif = data.tarif; + if (data.actif !== undefined) payload.actif = data.actif; + if (data.description !== undefined) payload.description = data.description; + + await type_usages.update(payload, { transaction }); + + return type_usages; + } + + static async deleteByIds(ids, options) { + const transaction = (options && options.transaction) || undefined; + + const records = await db.type_usages.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + for (const record of records) { + await record.destroy({ transaction }); + } + + return records; + } + + static async remove(id, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.type_usages.findByPk(id, { transaction }); + + await record.destroy({ transaction }); + + return record; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const record = await db.type_usages.findOne({ + where, + transaction, + }); + + if (!record) { + return record; + } + + return record.get({ plain: true }); + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + const currentPage = +filter.page || 0; + const offset = currentPage * limit; + let where = {}; + + if (filter.id) { + where.id = Utils.uuid(filter.id); + } + + if (filter.nom) { + where = { + ...where, + [Op.and]: Utils.ilike('type_usages', 'nom', filter.nom), + }; + } + + if (filter.description) { + where = { + ...where, + [Op.and]: Utils.ilike('type_usages', 'description', filter.description), + }; + } + + if (filter.actif !== undefined) { + where.actif = filter.actif === true || filter.actif === 'true'; + } + + if (filter.tarifRange) { + const values = Array.isArray(filter.tarifRange) + ? filter.tarifRange + : [filter.tarifRange]; + const [start, end] = values; + + if (start !== undefined && start !== '') { + where.tarif = { + ...where.tarif, + [Op.gte]: start, + }; + } + + if (end !== undefined && end !== '') { + where.tarif = { + ...where.tarif, + [Op.lte]: end, + }; + } + } + + const queryOptions = { + where, + distinct: true, + order: filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['nom', 'asc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + const { rows, count } = await db.type_usages.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count, + }; + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike('type_usages', 'nom', query), + ], + }; + } + + const records = await db.type_usages.findAll({ + attributes: ['id', 'nom'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['nom', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.nom, + })); + } +}; diff --git a/backend/src/db/api/villages.js b/backend/src/db/api/villages.js new file mode 100644 index 0000000..7dc71b3 --- /dev/null +++ b/backend/src/db/api/villages.js @@ -0,0 +1,226 @@ +const db = require('../models'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class VillagesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + return db.villages.create( + { + id: data.id || undefined, + nom: data.nom || null, + commune: data.commune || null, + arrondissement: data.arrondissement || null, + departement: data.departement || null, + region: data.region || null, + duree_periode_jours: data.duree_periode_jours || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const rows = data.map((item, index) => ({ + id: item.id || undefined, + nom: item.nom || null, + commune: item.commune || null, + arrondissement: item.arrondissement || null, + departement: item.departement || null, + region: item.region || null, + duree_periode_jours: item.duree_periode_jours || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + return db.villages.bulkCreate(rows, { transaction }); + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const record = await db.villages.findByPk(id, { transaction }); + + const payload = { + updatedById: currentUser.id, + }; + + if (data.nom !== undefined) payload.nom = data.nom; + if (data.commune !== undefined) payload.commune = data.commune; + if (data.arrondissement !== undefined) payload.arrondissement = data.arrondissement; + if (data.departement !== undefined) payload.departement = data.departement; + if (data.region !== undefined) payload.region = data.region; + if (data.duree_periode_jours !== undefined) payload.duree_periode_jours = data.duree_periode_jours; + if (data.notes !== undefined) payload.notes = data.notes; + + await record.update(payload, { transaction }); + + return record; + } + + static async deleteByIds(ids, options) { + const transaction = (options && options.transaction) || undefined; + + const records = await db.villages.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + for (const record of records) { + await record.destroy({ transaction }); + } + + return records; + } + + static async remove(id, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.villages.findByPk(id, { transaction }); + + await record.destroy({ transaction }); + + return record; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.villages.findOne({ + where, + include: [ + { + model: db.quartiers, + as: 'quartiers', + attributes: ['id', 'nom'], + }, + ], + transaction, + }); + + if (!record) { + return record; + } + + return record.get({ plain: true }); + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + const currentPage = +filter.page || 0; + const offset = currentPage * limit; + let where = {}; + + if (filter.id) { + where.id = Utils.uuid(filter.id); + } + + if (filter.nom) { + where = { + ...where, + [Op.and]: Utils.ilike('villages', 'nom', filter.nom), + }; + } + + for (const field of ['commune', 'arrondissement', 'departement', 'region', 'notes']) { + if (filter[field]) { + where = { + ...where, + [Op.and]: Utils.ilike('villages', field, filter[field]), + }; + } + } + + if (filter.duree_periode_joursRange) { + const values = Array.isArray(filter.duree_periode_joursRange) + ? filter.duree_periode_joursRange + : [filter.duree_periode_joursRange]; + const [start, end] = values; + + if (start !== undefined && start !== '') { + where.duree_periode_jours = { + ...where.duree_periode_jours, + [Op.gte]: start, + }; + } + + if (end !== undefined && end !== '') { + where.duree_periode_jours = { + ...where.duree_periode_jours, + [Op.lte]: end, + }; + } + } + + const queryOptions = { + where, + include: [ + { + model: db.quartiers, + as: 'quartiers', + attributes: ['id', 'nom'], + required: false, + }, + ], + distinct: true, + order: filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['nom', 'asc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + const { rows, count } = await db.villages.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count, + }; + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike('villages', 'nom', query), + ], + }; + } + + const records = await db.villages.findAll({ + attributes: ['id', 'nom'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['nom', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.nom, + })); + } +}; diff --git a/backend/src/db/migrations/20260329060000-gforage-reference-data.js b/backend/src/db/migrations/20260329060000-gforage-reference-data.js new file mode 100644 index 0000000..8ba8552 --- /dev/null +++ b/backend/src/db/migrations/20260329060000-gforage-reference-data.js @@ -0,0 +1,214 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const villageTable = await queryInterface.sequelize.query( + "SELECT to_regclass('public.villages') AS table_name;", + { transaction, type: Sequelize.QueryTypes.SELECT }, + ); + + if (!villageTable[0]?.table_name) { + await queryInterface.createTable( + 'type_usages', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: Sequelize.DataTypes.STRING(50), + allowNull: false, + unique: true, + }, + tarif: { + type: Sequelize.DataTypes.DECIMAL(10, 2), + allowNull: false, + defaultValue: 0, + }, + actif: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + description: { + type: Sequelize.DataTypes.TEXT, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex('type_usages', ['actif'], { + name: 'type_usages_actif_idx', + transaction, + }); + + await queryInterface.createTable( + 'villages', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: Sequelize.DataTypes.STRING(100), + allowNull: false, + unique: true, + }, + commune: { + type: Sequelize.DataTypes.STRING(100), + }, + arrondissement: { + type: Sequelize.DataTypes.STRING(100), + }, + departement: { + type: Sequelize.DataTypes.STRING(100), + }, + region: { + type: Sequelize.DataTypes.STRING(100), + }, + duree_periode_jours: { + type: Sequelize.DataTypes.INTEGER, + }, + notes: { + type: Sequelize.DataTypes.TEXT, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'quartiers', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: Sequelize.DataTypes.STRING(100), + allowNull: false, + }, + notes: { + type: Sequelize.DataTypes.TEXT, + }, + villageId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'villages', + }, + onDelete: 'RESTRICT', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addConstraint('quartiers', { + fields: ['nom', 'villageId'], + type: 'unique', + name: 'quartiers_nom_village_unique', + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const villageTable = await queryInterface.sequelize.query( + "SELECT to_regclass('public.villages') AS table_name;", + { transaction, type: Sequelize.QueryTypes.SELECT }, + ); + + if (!villageTable[0]?.table_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('quartiers', { transaction }); + await queryInterface.dropTable('villages', { transaction }); + await queryInterface.dropTable('type_usages', { transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/quartiers.js b/backend/src/db/models/quartiers.js new file mode 100644 index 0000000..2cc9048 --- /dev/null +++ b/backend/src/db/models/quartiers.js @@ -0,0 +1,50 @@ +module.exports = function(sequelize, DataTypes) { + const quartiers = sequelize.define( + 'quartiers', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: DataTypes.STRING(100), + allowNull: false, + }, + notes: { + type: DataTypes.TEXT, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + quartiers.associate = (db) => { + db.quartiers.belongsTo(db.villages, { + as: 'village', + foreignKey: { + name: 'villageId', + allowNull: false, + }, + constraints: false, + }); + + db.quartiers.belongsTo(db.users, { + as: 'createdBy', + }); + + db.quartiers.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return quartiers; +}; diff --git a/backend/src/db/models/type_usages.js b/backend/src/db/models/type_usages.js new file mode 100644 index 0000000..b1006da --- /dev/null +++ b/backend/src/db/models/type_usages.js @@ -0,0 +1,51 @@ +module.exports = function(sequelize, DataTypes) { + const type_usages = sequelize.define( + 'type_usages', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: DataTypes.STRING(50), + allowNull: false, + }, + tarif: { + type: DataTypes.DECIMAL(10, 2), + allowNull: false, + defaultValue: 0, + }, + actif: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + description: { + type: DataTypes.TEXT, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + type_usages.associate = (db) => { + db.type_usages.belongsTo(db.users, { + as: 'createdBy', + }); + + db.type_usages.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return type_usages; +}; diff --git a/backend/src/db/models/villages.js b/backend/src/db/models/villages.js new file mode 100644 index 0000000..07c409f --- /dev/null +++ b/backend/src/db/models/villages.js @@ -0,0 +1,62 @@ +module.exports = function(sequelize, DataTypes) { + const villages = sequelize.define( + 'villages', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + nom: { + type: DataTypes.STRING(100), + allowNull: false, + }, + commune: { + type: DataTypes.STRING(100), + }, + arrondissement: { + type: DataTypes.STRING(100), + }, + departement: { + type: DataTypes.STRING(100), + }, + region: { + type: DataTypes.STRING(100), + }, + duree_periode_jours: { + type: DataTypes.INTEGER, + }, + notes: { + type: DataTypes.TEXT, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + villages.associate = (db) => { + db.villages.hasMany(db.quartiers, { + as: 'quartiers', + foreignKey: 'villageId', + constraints: false, + }); + + db.villages.belongsTo(db.users, { + as: 'createdBy', + }); + + db.villages.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return villages; +}; diff --git a/backend/src/db/seeders/20260329061000-gforage-reference-permissions.js b/backend/src/db/seeders/20260329061000-gforage-reference-permissions.js new file mode 100644 index 0000000..107a7ac --- /dev/null +++ b/backend/src/db/seeders/20260329061000-gforage-reference-permissions.js @@ -0,0 +1,115 @@ +const { QueryTypes } = require('sequelize'); +const { v4: uuid } = require('uuid'); + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const createdAt = new Date(); + const updatedAt = new Date(); + const entities = ['type_usages', 'villages', 'quartiers']; + const actions = ['CREATE', 'READ', 'UPDATE', 'DELETE']; + + const roles = await queryInterface.sequelize.query( + 'SELECT id, name FROM roles WHERE name IN (:roles);', + { + replacements: { roles: ['Administrator', 'Platform Owner', 'Product Manager'] }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + for (const entity of entities) { + for (const action of actions) { + const name = `${action}_${entity.toUpperCase()}`; + const existing = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name = :name LIMIT 1;', + { + replacements: { name }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + let permissionId = existing[0]?.id; + + if (!permissionId) { + permissionId = uuid(); + await queryInterface.bulkInsert( + 'permissions', + [{ id: permissionId, name, createdAt, updatedAt }], + { transaction }, + ); + } + + for (const role of roles) { + const link = await queryInterface.sequelize.query( + 'SELECT 1 FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId LIMIT 1;', + { + replacements: { roleId: role.id, permissionId }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + if (!link.length) { + await queryInterface.bulkInsert( + 'rolesPermissionsPermissions', + [{ + createdAt, + updatedAt, + roles_permissionsId: role.id, + permissionId, + }], + { transaction }, + ); + } + } + } + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const permissions = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name LIKE ANY (ARRAY[:patterns]);', + { + replacements: { + patterns: ['%TYPE_USAGES', '%VILLAGES', '%QUARTIERS'], + }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + const ids = permissions.map((item) => item.id); + + if (ids.length) { + await queryInterface.bulkDelete( + 'rolesPermissionsPermissions', + { permissionId: ids }, + { transaction }, + ); + await queryInterface.bulkDelete( + 'permissions', + { id: ids }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 347d98d..ef32319 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -36,6 +36,9 @@ const messagesRoutes = require('./routes/messages'); const audit_logsRoutes = require('./routes/audit_logs'); const app_settingsRoutes = require('./routes/app_settings'); +const villagesRoutes = require('./routes/villages'); +const quartiersRoutes = require('./routes/quartiers'); +const type_usagesRoutes = require('./routes/type_usages'); const getBaseUrl = (url) => { @@ -110,6 +113,9 @@ app.use('/api/messages', passport.authenticate('jwt', {session: false}), message app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit_logsRoutes); app.use('/api/app_settings', passport.authenticate('jwt', {session: false}), app_settingsRoutes); +app.use('/api/villages', passport.authenticate('jwt', {session: false}), villagesRoutes); +app.use('/api/quartiers', passport.authenticate('jwt', {session: false}), quartiersRoutes); +app.use('/api/type_usages', passport.authenticate('jwt', {session: false}), type_usagesRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/quartiers.js b/backend/src/routes/quartiers.js new file mode 100644 index 0000000..f2db511 --- /dev/null +++ b/backend/src/routes/quartiers.js @@ -0,0 +1,78 @@ +const express = require('express'); +const QuartiersService = require('../services/quartiers'); +const QuartiersDBApi = require('../db/api/quartiers'); +const wrapAsync = require('../helpers').wrapAsync; +const { parse } = require('json2csv'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.use(checkCrudPermissions('quartiers')); + +router.post('/', wrapAsync(async (req, res) => { + await QuartiersService.create(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.post('/bulk-import', wrapAsync(async (req, res) => { + await QuartiersService.bulkImport(req, res); + res.status(200).send(true); +})); + +router.put('/:id', wrapAsync(async (req, res) => { + await QuartiersService.update(req.body.data, req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.delete('/:id', wrapAsync(async (req, res) => { + await QuartiersService.remove(req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await QuartiersService.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.get('/', wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + const payload = await QuartiersDBApi.findAll(req.query, { currentUser: req.currentUser }); + + if (filetype === 'csv') { + const fields = ['id', 'nom', 'notes', 'villageId']; + const csv = parse(payload.rows, { fields }); + res.status(200).attachment(csv); + res.send(csv); + return; + } + + res.status(200).send(payload); +})); + +router.get('/count', wrapAsync(async (req, res) => { + const payload = await QuartiersDBApi.findAll(req.query, { + countOnly: true, + currentUser: req.currentUser, + }); + + res.status(200).send(payload); +})); + +router.get('/autocomplete', wrapAsync(async (req, res) => { + const payload = await QuartiersDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +})); + +router.get('/:id', wrapAsync(async (req, res) => { + const payload = await QuartiersDBApi.findBy({ id: req.params.id }); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/type_usages.js b/backend/src/routes/type_usages.js new file mode 100644 index 0000000..d9c7f43 --- /dev/null +++ b/backend/src/routes/type_usages.js @@ -0,0 +1,78 @@ +const express = require('express'); +const Type_usagesService = require('../services/type_usages'); +const Type_usagesDBApi = require('../db/api/type_usages'); +const wrapAsync = require('../helpers').wrapAsync; +const { parse } = require('json2csv'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.use(checkCrudPermissions('type_usages')); + +router.post('/', wrapAsync(async (req, res) => { + await Type_usagesService.create(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.post('/bulk-import', wrapAsync(async (req, res) => { + await Type_usagesService.bulkImport(req, res); + res.status(200).send(true); +})); + +router.put('/:id', wrapAsync(async (req, res) => { + await Type_usagesService.update(req.body.data, req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.delete('/:id', wrapAsync(async (req, res) => { + await Type_usagesService.remove(req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await Type_usagesService.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.get('/', wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + const payload = await Type_usagesDBApi.findAll(req.query, { currentUser: req.currentUser }); + + if (filetype === 'csv') { + const fields = ['id', 'nom', 'tarif', 'actif', 'description']; + const csv = parse(payload.rows, { fields }); + res.status(200).attachment(csv); + res.send(csv); + return; + } + + res.status(200).send(payload); +})); + +router.get('/count', wrapAsync(async (req, res) => { + const payload = await Type_usagesDBApi.findAll(req.query, { + countOnly: true, + currentUser: req.currentUser, + }); + + res.status(200).send(payload); +})); + +router.get('/autocomplete', wrapAsync(async (req, res) => { + const payload = await Type_usagesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +})); + +router.get('/:id', wrapAsync(async (req, res) => { + const payload = await Type_usagesDBApi.findBy({ id: req.params.id }); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/villages.js b/backend/src/routes/villages.js new file mode 100644 index 0000000..f58d917 --- /dev/null +++ b/backend/src/routes/villages.js @@ -0,0 +1,78 @@ +const express = require('express'); +const VillagesService = require('../services/villages'); +const VillagesDBApi = require('../db/api/villages'); +const wrapAsync = require('../helpers').wrapAsync; +const { parse } = require('json2csv'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.use(checkCrudPermissions('villages')); + +router.post('/', wrapAsync(async (req, res) => { + await VillagesService.create(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.post('/bulk-import', wrapAsync(async (req, res) => { + await VillagesService.bulkImport(req, res); + res.status(200).send(true); +})); + +router.put('/:id', wrapAsync(async (req, res) => { + await VillagesService.update(req.body.data, req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.delete('/:id', wrapAsync(async (req, res) => { + await VillagesService.remove(req.params.id, req.currentUser); + res.status(200).send(true); +})); + +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await VillagesService.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +})); + +router.get('/', wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + const payload = await VillagesDBApi.findAll(req.query, { currentUser: req.currentUser }); + + if (filetype === 'csv') { + const fields = ['id', 'nom', 'commune', 'arrondissement', 'departement', 'region', 'duree_periode_jours', 'notes']; + const csv = parse(payload.rows, { fields }); + res.status(200).attachment(csv); + res.send(csv); + return; + } + + res.status(200).send(payload); +})); + +router.get('/count', wrapAsync(async (req, res) => { + const payload = await VillagesDBApi.findAll(req.query, { + countOnly: true, + currentUser: req.currentUser, + }); + + res.status(200).send(payload); +})); + +router.get('/autocomplete', wrapAsync(async (req, res) => { + const payload = await VillagesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +})); + +router.get('/:id', wrapAsync(async (req, res) => { + const payload = await VillagesDBApi.findBy({ id: req.params.id }); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/quartiers.js b/backend/src/services/quartiers.js new file mode 100644 index 0000000..3a31b4c --- /dev/null +++ b/backend/src/services/quartiers.js @@ -0,0 +1,111 @@ +const db = require('../db/models'); +const QuartiersDBApi = require('../db/api/quartiers'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const stream = require('stream'); + +module.exports = class QuartiersService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await QuartiersDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + 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')); + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => resolve()) + .on('error', (error) => reject(error)); + }); + + await QuartiersDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const existing = await QuartiersDBApi.findBy({ id }, { transaction }); + + if (!existing) { + throw new ValidationError('quartiersNotFound'); + } + + const updated = await QuartiersDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updated; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await QuartiersDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await QuartiersDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/type_usages.js b/backend/src/services/type_usages.js new file mode 100644 index 0000000..81db7a2 --- /dev/null +++ b/backend/src/services/type_usages.js @@ -0,0 +1,111 @@ +const db = require('../db/models'); +const Type_usagesDBApi = require('../db/api/type_usages'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const stream = require('stream'); + +module.exports = class Type_usagesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Type_usagesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + 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')); + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => resolve()) + .on('error', (error) => reject(error)); + }); + + await Type_usagesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const existing = await Type_usagesDBApi.findBy({ id }, { transaction }); + + if (!existing) { + throw new ValidationError('type_usagesNotFound'); + } + + const updated = await Type_usagesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updated; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Type_usagesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Type_usagesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/villages.js b/backend/src/services/villages.js new file mode 100644 index 0000000..4504c03 --- /dev/null +++ b/backend/src/services/villages.js @@ -0,0 +1,111 @@ +const db = require('../db/models'); +const VillagesDBApi = require('../db/api/villages'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const stream = require('stream'); + +module.exports = class VillagesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await VillagesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + 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')); + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => resolve()) + .on('error', (error) => reject(error)); + }); + + await VillagesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const existing = await VillagesDBApi.findBy({ id }, { transaction }); + + if (!existing) { + throw new ValidationError('villagesNotFound'); + } + + const updated = await VillagesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updated; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await VillagesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await VillagesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/public/assets/vm-shot-2026-03-29T05-06-17-032Z.jpg b/frontend/public/assets/vm-shot-2026-03-29T05-06-17-032Z.jpg new file mode 100644 index 0000000..a80c88f Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-03-29T05-06-17-032Z.jpg differ diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 52fda01..8726131 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -72,6 +72,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiCogOutline' in icon ? icon['mdiCogOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_APP_SETTINGS' }, + { + href: '/g-forage/referentials', + label: 'Référentiels forage', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiDatabaseCogOutline' in icon ? icon['mdiDatabaseCogOutline' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_VILLAGES' + }, { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/g-forage/referentials.tsx b/frontend/src/pages/g-forage/referentials.tsx new file mode 100644 index 0000000..3348c16 --- /dev/null +++ b/frontend/src/pages/g-forage/referentials.tsx @@ -0,0 +1,191 @@ +import { mdiTable } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import axios from 'axios'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { useAppSelector } from '../../stores/hooks'; + +type ItemRow = { + id: string; + nom: string; + commune?: string; + tarif?: number | string; + actif?: boolean; + village?: { nom?: string }; +}; + +const ReferentialsPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [stats, setStats] = useState({ villages: 0, quartiers: 0, typeUsages: 0 }); + const [villages, setVillages] = useState([]); + const [quartiers, setQuartiers] = useState([]); + const [typeUsages, setTypeUsages] = useState([]); + + useEffect(() => { + if (!currentUser) return; + + const loadData = async () => { + try { + setLoading(true); + setError(''); + + const [villagesCount, quartiersCount, typeUsagesCount, villagesRows, quartiersRows, typeUsagesRows] = await Promise.all([ + axios.get('/villages/count'), + axios.get('/quartiers/count'), + axios.get('/type_usages/count'), + axios.get('/villages', { params: { page: 0, limit: 5 } }), + axios.get('/quartiers', { params: { page: 0, limit: 5 } }), + axios.get('/type_usages', { params: { page: 0, limit: 5 } }), + ]); + + setStats({ + villages: villagesCount.data.count ?? 0, + quartiers: quartiersCount.data.count ?? 0, + typeUsages: typeUsagesCount.data.count ?? 0, + }); + setVillages(villagesRows.data.rows ?? []); + setQuartiers(quartiersRows.data.rows ?? []); + setTypeUsages(typeUsagesRows.data.rows ?? []); + } catch (err) { + console.error('Failed to load G-Forage referentials:', err); + setError('Impossible de charger les référentiels G-Forage pour le moment.'); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [currentUser]); + + return ( + <> + + {getPageTitle('Référentiels G-Forage')} + + + + {''} + + +
+ +
Villages
+
{loading ? '…' : stats.villages}
+
+ +
Quartiers
+
{loading ? '…' : stats.quartiers}
+
+ +
Types d’usage
+
{loading ? '…' : stats.typeUsages}
+
+
+ + {error && ( + +

{error}

+
+ )} + +
+ +

Derniers villages

+ + + + + + + + + {villages.map((item) => ( + + + + + ))} + {!loading && villages.length === 0 && ( + + + + )} + +
NomCommune
{item.nom}{item.commune || '—'}
Aucun village pour le moment.
+
+ + +

Derniers quartiers

+ + + + + + + + + {quartiers.map((item) => ( + + + + + ))} + {!loading && quartiers.length === 0 && ( + + + + )} + +
NomVillage
{item.nom}{item.village?.nom || '—'}
Aucun quartier pour le moment.
+
+ + +

Types d’usage

+ + + + + + + + + {typeUsages.map((item) => ( + + + + + ))} + {!loading && typeUsages.length === 0 && ( + + + + )} + +
NomTarif
{item.nom}{item.tarif ?? 0} F
Aucun type d’usage pour le moment.
+
+
+
+ + ); +}; + +ReferentialsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ReferentialsPage;