commit e1316ddd59db42e1e1c4bc2705e8508749340fd4 Author: Flatlogic Bot Date: Sun Mar 9 14:08:45 2025 +0000 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3eb8c11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*/node_modules/ +app-shell/ +*/build/ diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..bb087f2 --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "printWidth": 80, + "trailingComma": "all", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always" +} diff --git a/backend/.sequelizerc b/backend/.sequelizerc new file mode 100644 index 0000000..fe89188 --- /dev/null +++ b/backend/.sequelizerc @@ -0,0 +1,7 @@ +const path = require('path'); +module.exports = { + "config": path.resolve("src", "db", "db.config.js"), + "models-path": path.resolve("src", "db", "models"), + "seeders-path": path.resolve("src", "db", "seeders"), + "migrations-path": path.resolve("src", "db", "migrations") +}; \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..581cb98 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20.15.1-alpine + +RUN apk update && apk add bash +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN yarn install +# If you are building your code for production +# RUN npm ci --only=production + + +# Bundle app source +COPY . . + + +EXPOSE 8080 + +CMD [ "yarn", "start" ] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8c8326e --- /dev/null +++ b/backend/README.md @@ -0,0 +1,67 @@ +#mental health wellbing app - template backend, + +#### Run App on local machine: + +##### Install local dependencies: + +- `yarn install` + +--- + +##### Adjust local db: + +###### 1. Install postgres: + +- MacOS: + + - `brew install postgres` + +- Ubuntu: + - `sudo apt update` + - `sudo apt install postgresql postgresql-contrib` + +###### 2. Create db and admin user: + +- Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database. + + - `psql postgres --u postgres` + +- Next, type this command for creating a new user with password then give access for creating the database. + + - `postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';` + - `postgres-# ALTER ROLE admin CREATEDB;` + +- Quit `psql` then log in again using the new user that previously created. + + - `postgres-# \q` + - `psql postgres -U admin` + +- Type this command to creating a new database. + + - `postgres=> CREATE DATABASE db_mental_health_wellbing_app;` + +- Then give that new user privileges to the new database then quit the `psql`. + - `postgres=> GRANT ALL PRIVILEGES ON DATABASE db_mental_health_wellbing_app TO admin;` + - `postgres=> \q` + +--- + +#### Api Documentation (Swagger) + +http://localhost:8080/api-docs (local host) + +http://host_name/api-docs + +--- + +##### Setup database tables or update after schema change + +- `yarn db:migrate` + +##### Seed the initial data (admin accounts, relevant for the first setup): + +- `yarn db:seed` + +##### Start build: + +- `yarn start` diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..116299d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,51 @@ +{ + "name": "mentalhealthwellbingapp", + "description": "mental health wellbing app - template backend", + "scripts": { + "start": "npm run db:migrate && npm run db:seed && nodemon ./src/index.js", + "db:migrate": "sequelize-cli db:migrate", + "db:seed": "sequelize-cli db:seed:all", + "db:drop": "sequelize-cli db:drop", + "db:create": "sequelize-cli db:create" + }, + "dependencies": { + "@google-cloud/storage": "^5.18.2", + "axios": "^1.6.7", + "bcrypt": "5.1.1", + "cors": "2.8.5", + "csv-parser": "^3.0.0", + "express": "4.18.2", + "formidable": "1.2.2", + "helmet": "4.1.1", + "json2csv": "^5.0.7", + "jsonwebtoken": "8.5.1", + "lodash": "4.17.21", + "moment": "2.30.1", + "multer": "^1.4.4", + "mysql2": "2.2.5", + "nodemailer": "6.9.9", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-microsoft": "^0.1.0", + "pg": "8.4.1", + "pg-hstore": "2.3.4", + "sequelize": "6.35.2", + "sequelize-json-schema": "^2.1.1", + "sqlite": "4.0.15", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "tedious": "^18.2.4" + }, + "engines": { + "node": ">=18" + }, + "private": true, + "devDependencies": { + "cross-env": "7.0.3", + "mocha": "8.1.3", + "node-mocks-http": "1.9.0", + "nodemon": "2.0.5", + "sequelize-cli": "6.6.2" + } +} diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js new file mode 100644 index 0000000..0634d88 --- /dev/null +++ b/backend/src/auth/auth.js @@ -0,0 +1,79 @@ +const config = require('../config'); +const providers = config.providers; +const helpers = require('../helpers'); +const db = require('../db/models'); + +const passport = require('passport'); +const JWTstrategy = require('passport-jwt').Strategy; +const ExtractJWT = require('passport-jwt').ExtractJwt; +const GoogleStrategy = require('passport-google-oauth2').Strategy; +const MicrosoftStrategy = require('passport-microsoft').Strategy; +const UsersDBApi = require('../db/api/users'); + +passport.use( + new JWTstrategy( + { + passReqToCallback: true, + secretOrKey: config.secret_key, + jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), + }, + async (req, token, done) => { + try { + const user = await UsersDBApi.findBy({ email: token.user.email }); + + if (user && user.disabled) { + return done(new Error(`User '${user.email}' is disabled`)); + } + + req.currentUser = user; + + return done(null, user); + } catch (error) { + done(error); + } + }, + ), +); + +passport.use( + new GoogleStrategy( + { + clientID: config.google.clientId, + clientSecret: config.google.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/google/callback', + passReqToCallback: true, + }, + function (request, accessToken, refreshToken, profile, done) { + socialStrategy(profile.email, profile, providers.GOOGLE, done); + }, + ), +); + +passport.use( + new MicrosoftStrategy( + { + clientID: config.microsoft.clientId, + clientSecret: config.microsoft.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', + passReqToCallback: true, + }, + function (request, accessToken, refreshToken, profile, done) { + const email = profile._json.mail || profile._json.userPrincipalName; + socialStrategy(email, profile, providers.MICROSOFT, done); + }, + ), +); + +function socialStrategy(email, profile, provider, done) { + db.users + .findOrCreate({ where: { email, provider } }) + .then(([user, created]) => { + const body = { + id: user.id, + email: user.email, + name: profile.displayName, + }; + const token = helpers.jwtSign({ user: body }); + return done(null, { token }); + }); +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..1eca5d9 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,71 @@ +const os = require('os'); + +const config = { + gcloud: { + bucket: 'fldemo-files', + hash: '8420f21d3df929c8414cb0f0cdf61fcb', + }, + bcrypt: { + saltRounds: 12, + }, + admin_pass: 'password', + admin_email: 'admin@flatlogic.com', + providers: { + LOCAL: 'local', + GOOGLE: 'google', + MICROSOFT: 'microsoft', + }, + secret_key: 'HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA', + remote: '', + port: process.env.NODE_ENV === 'production' ? '' : '8080', + hostUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost', + portUI: process.env.NODE_ENV === 'production' ? '' : '3000', + + portUIProd: process.env.NODE_ENV === 'production' ? '' : ':3000', + + swaggerUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost', + swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080', + google: { + clientId: + '671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com', + clientSecret: 'Yo4qbKZniqvojzUQ60iKlxqR', + }, + microsoft: { + clientId: '4696f457-31af-40de-897c-e00d7d4cff73', + clientSecret: 'm8jzZ.5UpHF3=-dXzyxiZ4e[F8OF54@p', + }, + uploadDir: os.tmpdir(), + email: { + from: 'mental health wellbing app ', + host: 'email-smtp.us-east-1.amazonaws.com', + port: 587, + auth: { + user: 'AKIAVEW7G4PQUBGM52OF', + pass: process.env.EMAIL_PASS, + }, + tls: { + rejectUnauthorized: false, + }, + }, + roles: { + admin: 'Administrator', + user: 'User', + }, + + project_uuid: 'a87cb00c-a670-4b2c-8fb7-d119b7031414', + flHost: + process.env.NODE_ENV === 'production' || + process.env.NODE_ENV === 'dev_stage' + ? 'https://flatlogic.com/projects' + : 'http://localhost:3000/projects', +}; +config.pexelsKey = 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18'; +config.pexelsQuery = 'calm nature landscape'; +config.host = + process.env.NODE_ENV === 'production' ? config.remote : 'http://localhost'; +config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; +config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; +config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; +config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`; + +module.exports = config; diff --git a/backend/src/db/api/administrators.js b/backend/src/db/api/administrators.js new file mode 100644 index 0000000..a3742cc --- /dev/null +++ b/backend/src/db/api/administrators.js @@ -0,0 +1,300 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class AdministratorsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const administrators = await db.administrators.create( + { + id: data.id || undefined, + + role: data.role || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await administrators.setUser(data.user || null, { + transaction, + }); + + return administrators; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const administratorsData = data.map((item, index) => ({ + id: item.id || undefined, + + role: item.role || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const administrators = await db.administrators.bulkCreate( + administratorsData, + { transaction }, + ); + + // For each item created, replace relation files + + return administrators; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const administrators = await db.administrators.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.role !== undefined) updatePayload.role = data.role; + + updatePayload.updatedById = currentUser.id; + + await administrators.update(updatePayload, { transaction }); + + if (data.user !== undefined) { + await administrators.setUser( + data.user, + + { transaction }, + ); + } + + return administrators; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const administrators = await db.administrators.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of administrators) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of administrators) { + await record.destroy({ transaction }); + } + }); + + return administrators; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const administrators = await db.administrators.findByPk(id, options); + + await administrators.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await administrators.destroy({ + transaction, + }); + + return administrators; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const administrators = await db.administrators.findOne( + { where }, + { transaction }, + ); + + if (!administrators) { + return administrators; + } + + const output = administrators.get({ plain: true }); + + output.user = await administrators.getUser({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.users, + as: 'user', + + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.role) { + where = { + ...where, + [Op.and]: Utils.ilike('administrators', 'role', filter.role), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.administrators.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('administrators', 'role', query), + ], + }; + } + + const records = await db.administrators.findAll({ + attributes: ['id', 'role'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['role', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.role, + })); + } +}; diff --git a/backend/src/db/api/clients.js b/backend/src/db/api/clients.js new file mode 100644 index 0000000..8c8179f --- /dev/null +++ b/backend/src/db/api/clients.js @@ -0,0 +1,341 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ClientsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.create( + { + id: data.id || undefined, + + mood: data.mood || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await clients.setUser(data.user || null, { + transaction, + }); + + await clients.setSessions(data.sessions || [], { + transaction, + }); + + return clients; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const clientsData = data.map((item, index) => ({ + id: item.id || undefined, + + mood: item.mood || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const clients = await db.clients.bulkCreate(clientsData, { transaction }); + + // For each item created, replace relation files + + return clients; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.mood !== undefined) updatePayload.mood = data.mood; + + updatePayload.updatedById = currentUser.id; + + await clients.update(updatePayload, { transaction }); + + if (data.user !== undefined) { + await clients.setUser( + data.user, + + { transaction }, + ); + } + + if (data.sessions !== undefined) { + await clients.setSessions(data.sessions, { transaction }); + } + + return clients; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of clients) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of clients) { + await record.destroy({ transaction }); + } + }); + + return clients; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findByPk(id, options); + + await clients.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await clients.destroy({ + transaction, + }); + + return clients; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findOne({ where }, { transaction }); + + if (!clients) { + return clients; + } + + const output = clients.get({ plain: true }); + + output.sessions_client = await clients.getSessions_client({ + transaction, + }); + + output.user = await clients.getUser({ + transaction, + }); + + output.sessions = await clients.getSessions({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.users, + as: 'user', + + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.sessions, + as: 'sessions', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.mood) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'mood', filter.mood), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.sessions) { + const searchTerms = filter.sessions.split('|'); + + include = [ + { + model: db.sessions, + as: 'sessions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + start_time: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.clients.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('clients', 'mood', query), + ], + }; + } + + const records = await db.clients.findAll({ + attributes: ['id', 'mood'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['mood', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.mood, + })); + } +}; diff --git a/backend/src/db/api/file.js b/backend/src/db/api/file.js new file mode 100644 index 0000000..22f9b6f --- /dev/null +++ b/backend/src/db/api/file.js @@ -0,0 +1,73 @@ +const db = require('../models'); +const assert = require('assert'); +const services = require('../../services/file'); + +module.exports = class FileDBApi { + static async replaceRelationFiles(relation, rawFiles, options) { + assert(relation.belongsTo, 'belongsTo is required'); + assert(relation.belongsToColumn, 'belongsToColumn is required'); + assert(relation.belongsToId, 'belongsToId is required'); + + let files = []; + + if (Array.isArray(rawFiles)) { + files = rawFiles; + } else { + files = rawFiles ? [rawFiles] : []; + } + + await this._removeLegacyFiles(relation, files, options); + await this._addFiles(relation, files, options); + } + + static async _addFiles(relation, files, options) { + const transaction = (options && options.transaction) || undefined; + const currentUser = (options && options.currentUser) || { id: null }; + + const inexistentFiles = files.filter((file) => !!file.new); + + for (const file of inexistentFiles) { + await db.file.create( + { + belongsTo: relation.belongsTo, + belongsToColumn: relation.belongsToColumn, + belongsToId: relation.belongsToId, + name: file.name, + sizeInBytes: file.sizeInBytes, + privateUrl: file.privateUrl, + publicUrl: file.publicUrl, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { + transaction, + }, + ); + } + } + + static async _removeLegacyFiles(relation, files, options) { + const transaction = (options && options.transaction) || undefined; + + const filesToDelete = await db.file.findAll({ + where: { + belongsTo: relation.belongsTo, + belongsToId: relation.belongsToId, + belongsToColumn: relation.belongsToColumn, + id: { + [db.Sequelize.Op.notIn]: files + .filter((file) => !file.new) + .map((file) => file.id), + }, + }, + transaction, + }); + + for (let file of filesToDelete) { + await services.deleteGCloud(file.privateUrl); + await file.destroy({ + transaction, + }); + } + } +}; diff --git a/backend/src/db/api/permissions.js b/backend/src/db/api/permissions.js new file mode 100644 index 0000000..c3017f2 --- /dev/null +++ b/backend/src/db/api/permissions.js @@ -0,0 +1,253 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class PermissionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return permissions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const permissionsData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const permissions = await db.permissions.bulkCreate(permissionsData, { + transaction, + }); + + // For each item created, replace relation files + + return permissions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await permissions.update(updatePayload, { transaction }); + + return permissions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of permissions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of permissions) { + await record.destroy({ transaction }); + } + }); + + return permissions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findByPk(id, options); + + await permissions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await permissions.destroy({ + transaction, + }); + + return permissions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findOne( + { where }, + { transaction }, + ); + + if (!permissions) { + return permissions; + } + + const output = permissions.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('permissions', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.permissions.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('permissions', 'name', query), + ], + }; + } + + const records = await db.permissions.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/resources.js b/backend/src/db/api/resources.js new file mode 100644 index 0000000..cb2f972 --- /dev/null +++ b/backend/src/db/api/resources.js @@ -0,0 +1,270 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ResourcesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const resources = await db.resources.create( + { + id: data.id || undefined, + + title: data.title || null, + content: data.content || null, + type: data.type || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return resources; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const resourcesData = data.map((item, index) => ({ + id: item.id || undefined, + + title: item.title || null, + content: item.content || null, + type: item.type || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const resources = await db.resources.bulkCreate(resourcesData, { + transaction, + }); + + // For each item created, replace relation files + + return resources; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const resources = await db.resources.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.title !== undefined) updatePayload.title = data.title; + + if (data.content !== undefined) updatePayload.content = data.content; + + if (data.type !== undefined) updatePayload.type = data.type; + + updatePayload.updatedById = currentUser.id; + + await resources.update(updatePayload, { transaction }); + + return resources; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const resources = await db.resources.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of resources) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of resources) { + await record.destroy({ transaction }); + } + }); + + return resources; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const resources = await db.resources.findByPk(id, options); + + await resources.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await resources.destroy({ + transaction, + }); + + return resources; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const resources = await db.resources.findOne({ where }, { transaction }); + + if (!resources) { + return resources; + } + + const output = resources.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.title) { + where = { + ...where, + [Op.and]: Utils.ilike('resources', 'title', filter.title), + }; + } + + if (filter.content) { + where = { + ...where, + [Op.and]: Utils.ilike('resources', 'content', filter.content), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.type) { + where = { + ...where, + type: filter.type, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.resources.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('resources', 'title', query), + ], + }; + } + + const records = await db.resources.findAll({ + attributes: ['id', 'title'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['title', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.title, + })); + } +}; diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js new file mode 100644 index 0000000..b59d342 --- /dev/null +++ b/backend/src/db/api/roles.js @@ -0,0 +1,315 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class RolesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.create( + { + id: data.id || undefined, + + name: data.name || null, + role_customization: data.role_customization || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await roles.setPermissions(data.permissions || [], { + transaction, + }); + + return roles; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const rolesData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + role_customization: item.role_customization || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const roles = await db.roles.bulkCreate(rolesData, { transaction }); + + // For each item created, replace relation files + + return roles; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + if (data.role_customization !== undefined) + updatePayload.role_customization = data.role_customization; + + updatePayload.updatedById = currentUser.id; + + await roles.update(updatePayload, { transaction }); + + if (data.permissions !== undefined) { + await roles.setPermissions(data.permissions, { transaction }); + } + + return roles; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of roles) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of roles) { + await record.destroy({ transaction }); + } + }); + + return roles; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findByPk(id, options); + + await roles.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await roles.destroy({ + transaction, + }); + + return roles; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findOne({ where }, { transaction }); + + if (!roles) { + return roles; + } + + const output = roles.get({ plain: true }); + + output.users_app_role = await roles.getUsers_app_role({ + transaction, + }); + + output.permissions = await roles.getPermissions({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.permissions, + as: 'permissions', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('roles', 'name', filter.name), + }; + } + + if (filter.role_customization) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'roles', + 'role_customization', + filter.role_customization, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.permissions) { + const searchTerms = filter.permissions.split('|'); + + include = [ + { + model: db.permissions, + as: 'permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.roles.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('roles', 'name', query), + ], + }; + } + + const records = await db.roles.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/sessions.js b/backend/src/db/api/sessions.js new file mode 100644 index 0000000..8d14789 --- /dev/null +++ b/backend/src/db/api/sessions.js @@ -0,0 +1,407 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class SessionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const sessions = await db.sessions.create( + { + id: data.id || undefined, + + start_time: data.start_time || null, + end_time: data.end_time || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await sessions.setTherapist(data.therapist || null, { + transaction, + }); + + await sessions.setClient(data.client || null, { + transaction, + }); + + return sessions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const sessionsData = data.map((item, index) => ({ + id: item.id || undefined, + + start_time: item.start_time || null, + end_time: item.end_time || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const sessions = await db.sessions.bulkCreate(sessionsData, { + transaction, + }); + + // For each item created, replace relation files + + return sessions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const sessions = await db.sessions.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.start_time !== undefined) + updatePayload.start_time = data.start_time; + + if (data.end_time !== undefined) updatePayload.end_time = data.end_time; + + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await sessions.update(updatePayload, { transaction }); + + if (data.therapist !== undefined) { + await sessions.setTherapist( + data.therapist, + + { transaction }, + ); + } + + if (data.client !== undefined) { + await sessions.setClient( + data.client, + + { transaction }, + ); + } + + return sessions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const sessions = await db.sessions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of sessions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of sessions) { + await record.destroy({ transaction }); + } + }); + + return sessions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const sessions = await db.sessions.findByPk(id, options); + + await sessions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await sessions.destroy({ + transaction, + }); + + return sessions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const sessions = await db.sessions.findOne({ where }, { transaction }); + + if (!sessions) { + return sessions; + } + + const output = sessions.get({ plain: true }); + + output.therapist = await sessions.getTherapist({ + transaction, + }); + + output.client = await sessions.getClient({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.therapists, + as: 'therapist', + + where: filter.therapist + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.therapist + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + specialization: { + [Op.or]: filter.therapist + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.clients, + as: 'client', + + where: filter.client + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.client + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + mood: { + [Op.or]: filter.client + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('sessions', 'notes', filter.notes), + }; + } + + if (filter.calendarStart && filter.calendarEnd) { + where = { + ...where, + [Op.or]: [ + { + start_time: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + { + end_time: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + ], + }; + } + + if (filter.start_timeRange) { + const [start, end] = filter.start_timeRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + start_time: { + ...where.start_time, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + start_time: { + ...where.start_time, + [Op.lte]: end, + }, + }; + } + } + + if (filter.end_timeRange) { + const [start, end] = filter.end_timeRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + end_time: { + ...where.end_time, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + end_time: { + ...where.end_time, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.sessions.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('sessions', 'start_time', query), + ], + }; + } + + const records = await db.sessions.findAll({ + attributes: ['id', 'start_time'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['start_time', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.start_time, + })); + } +}; diff --git a/backend/src/db/api/supervisors.js b/backend/src/db/api/supervisors.js new file mode 100644 index 0000000..bb59c9a --- /dev/null +++ b/backend/src/db/api/supervisors.js @@ -0,0 +1,333 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class SupervisorsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const supervisors = await db.supervisors.create( + { + id: data.id || undefined, + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await supervisors.setUser(data.user || null, { + transaction, + }); + + await supervisors.setTherapists(data.therapists || [], { + transaction, + }); + + return supervisors; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const supervisorsData = data.map((item, index) => ({ + id: item.id || undefined, + + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const supervisors = await db.supervisors.bulkCreate(supervisorsData, { + transaction, + }); + + // For each item created, replace relation files + + return supervisors; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const supervisors = await db.supervisors.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + updatePayload.updatedById = currentUser.id; + + await supervisors.update(updatePayload, { transaction }); + + if (data.user !== undefined) { + await supervisors.setUser( + data.user, + + { transaction }, + ); + } + + if (data.therapists !== undefined) { + await supervisors.setTherapists(data.therapists, { transaction }); + } + + return supervisors; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const supervisors = await db.supervisors.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of supervisors) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of supervisors) { + await record.destroy({ transaction }); + } + }); + + return supervisors; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const supervisors = await db.supervisors.findByPk(id, options); + + await supervisors.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await supervisors.destroy({ + transaction, + }); + + return supervisors; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const supervisors = await db.supervisors.findOne( + { where }, + { transaction }, + ); + + if (!supervisors) { + return supervisors; + } + + const output = supervisors.get({ plain: true }); + + output.user = await supervisors.getUser({ + transaction, + }); + + output.therapists = await supervisors.getTherapists({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.users, + as: 'user', + + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.therapists, + as: 'therapists', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.therapists) { + const searchTerms = filter.therapists.split('|'); + + include = [ + { + model: db.therapists, + as: 'therapists_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + specialization: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.supervisors.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('supervisors', 'user', query), + ], + }; + } + + const records = await db.supervisors.findAll({ + attributes: ['id', 'user'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['user', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.user, + })); + } +}; diff --git a/backend/src/db/api/therapists.js b/backend/src/db/api/therapists.js new file mode 100644 index 0000000..78e316b --- /dev/null +++ b/backend/src/db/api/therapists.js @@ -0,0 +1,299 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class TherapistsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const therapists = await db.therapists.create( + { + id: data.id || undefined, + + specialization: data.specialization || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await therapists.setUser(data.user || null, { + transaction, + }); + + return therapists; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const therapistsData = data.map((item, index) => ({ + id: item.id || undefined, + + specialization: item.specialization || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const therapists = await db.therapists.bulkCreate(therapistsData, { + transaction, + }); + + // For each item created, replace relation files + + return therapists; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const therapists = await db.therapists.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.specialization !== undefined) + updatePayload.specialization = data.specialization; + + updatePayload.updatedById = currentUser.id; + + await therapists.update(updatePayload, { transaction }); + + if (data.user !== undefined) { + await therapists.setUser( + data.user, + + { transaction }, + ); + } + + return therapists; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const therapists = await db.therapists.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of therapists) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of therapists) { + await record.destroy({ transaction }); + } + }); + + return therapists; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const therapists = await db.therapists.findByPk(id, options); + + await therapists.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await therapists.destroy({ + transaction, + }); + + return therapists; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const therapists = await db.therapists.findOne({ where }, { transaction }); + + if (!therapists) { + return therapists; + } + + const output = therapists.get({ plain: true }); + + output.sessions_therapist = await therapists.getSessions_therapist({ + transaction, + }); + + output.user = await therapists.getUser({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.users, + as: 'user', + + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.specialization) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'therapists', + 'specialization', + filter.specialization, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.therapists.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('therapists', 'specialization', query), + ], + }; + } + + const records = await db.therapists.findAll({ + attributes: ['id', 'specialization'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['specialization', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.specialization, + })); + } +}; diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js new file mode 100644 index 0000000..0eda2a1 --- /dev/null +++ b/backend/src/db/api/users.js @@ -0,0 +1,758 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class UsersDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.create( + { + id: data.data.id || undefined, + + firstName: data.data.firstName || null, + lastName: data.data.lastName || null, + phoneNumber: data.data.phoneNumber || null, + email: data.data.email || null, + disabled: data.data.disabled || false, + + password: data.data.password || null, + emailVerified: data.data.emailVerified || true, + + emailVerificationToken: data.data.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + data.data.emailVerificationTokenExpiresAt || null, + passwordResetToken: data.data.passwordResetToken || null, + passwordResetTokenExpiresAt: + data.data.passwordResetTokenExpiresAt || null, + provider: data.data.provider || null, + importHash: data.data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + if (!data.data.app_role) { + const role = await db.roles.findOne({ + where: { name: 'User' }, + }); + if (role) { + await users.setApp_role(role, { + transaction, + }); + } + } else { + await users.setApp_role(data.data.app_role || null, { + transaction, + }); + } + + await users.setCustom_permissions(data.data.custom_permissions || [], { + transaction, + }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.data.avatar, + options, + ); + + return users; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const usersData = data.map((item, index) => ({ + id: item.id || undefined, + + firstName: item.firstName || null, + lastName: item.lastName || null, + phoneNumber: item.phoneNumber || null, + email: item.email || null, + disabled: item.disabled || false, + + password: item.password || null, + emailVerified: item.emailVerified || false, + + emailVerificationToken: item.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + item.emailVerificationTokenExpiresAt || null, + passwordResetToken: item.passwordResetToken || null, + passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, + provider: item.provider || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const users = await db.users.bulkCreate(usersData, { transaction }); + + // For each item created, replace relation files + + for (let i = 0; i < users.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users[i].id, + }, + data[i].avatar, + options, + ); + } + + return users; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, {}, { transaction }); + + if (!data?.app_role) { + data.app_role = users?.app_role?.id; + } + if (!data?.custom_permissions) { + data.custom_permissions = users?.custom_permissions?.map( + (item) => item.id, + ); + } + + if (data.password) { + data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds); + } else { + data.password = users.password; + } + + const updatePayload = {}; + + if (data.firstName !== undefined) updatePayload.firstName = data.firstName; + + if (data.lastName !== undefined) updatePayload.lastName = data.lastName; + + if (data.phoneNumber !== undefined) + updatePayload.phoneNumber = data.phoneNumber; + + if (data.email !== undefined) updatePayload.email = data.email; + + if (data.disabled !== undefined) updatePayload.disabled = data.disabled; + + if (data.password !== undefined) updatePayload.password = data.password; + + if (data.emailVerified !== undefined) + updatePayload.emailVerified = data.emailVerified; + else updatePayload.emailVerified = true; + + if (data.emailVerificationToken !== undefined) + updatePayload.emailVerificationToken = data.emailVerificationToken; + + if (data.emailVerificationTokenExpiresAt !== undefined) + updatePayload.emailVerificationTokenExpiresAt = + data.emailVerificationTokenExpiresAt; + + if (data.passwordResetToken !== undefined) + updatePayload.passwordResetToken = data.passwordResetToken; + + if (data.passwordResetTokenExpiresAt !== undefined) + updatePayload.passwordResetTokenExpiresAt = + data.passwordResetTokenExpiresAt; + + if (data.provider !== undefined) updatePayload.provider = data.provider; + + updatePayload.updatedById = currentUser.id; + + await users.update(updatePayload, { transaction }); + + if (data.app_role !== undefined) { + await users.setApp_role( + data.app_role, + + { transaction }, + ); + } + + if (data.custom_permissions !== undefined) { + await users.setCustom_permissions(data.custom_permissions, { + transaction, + }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.avatar, + options, + ); + + return users; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of users) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of users) { + await record.destroy({ transaction }); + } + }); + + return users; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, options); + + await users.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await users.destroy({ + transaction, + }); + + return users; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findOne({ where }, { transaction }); + + if (!users) { + return users; + } + + const output = users.get({ plain: true }); + + output.administrators_user = await users.getAdministrators_user({ + transaction, + }); + + output.clients_user = await users.getClients_user({ + transaction, + }); + + output.supervisors_user = await users.getSupervisors_user({ + transaction, + }); + + output.therapists_user = await users.getTherapists_user({ + transaction, + }); + + output.avatar = await users.getAvatar({ + transaction, + }); + + output.app_role = await users.getApp_role({ + transaction, + }); + + if (output.app_role) { + output.app_role_permissions = await output.app_role.getPermissions({ + transaction, + }); + } + + output.custom_permissions = await users.getCustom_permissions({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.roles, + as: 'app_role', + + where: filter.app_role + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.app_role + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.app_role + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.permissions, + as: 'custom_permissions', + }, + + { + model: db.file, + as: 'avatar', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.firstName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'firstName', filter.firstName), + }; + } + + if (filter.lastName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'lastName', filter.lastName), + }; + } + + if (filter.phoneNumber) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber), + }; + } + + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'email', filter.email), + }; + } + + if (filter.password) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'password', filter.password), + }; + } + + if (filter.emailVerificationToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'emailVerificationToken', + filter.emailVerificationToken, + ), + }; + } + + if (filter.passwordResetToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'passwordResetToken', + filter.passwordResetToken, + ), + }; + } + + if (filter.provider) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'provider', filter.provider), + }; + } + + if (filter.emailVerificationTokenExpiresAtRange) { + const [start, end] = filter.emailVerificationTokenExpiresAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { + ...where.emailVerificationTokenExpiresAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { + ...where.emailVerificationTokenExpiresAt, + [Op.lte]: end, + }, + }; + } + } + + if (filter.passwordResetTokenExpiresAtRange) { + const [start, end] = filter.passwordResetTokenExpiresAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + passwordResetTokenExpiresAt: { + ...where.passwordResetTokenExpiresAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + passwordResetTokenExpiresAt: { + ...where.passwordResetTokenExpiresAt, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.disabled) { + where = { + ...where, + disabled: filter.disabled, + }; + } + + if (filter.emailVerified) { + where = { + ...where, + emailVerified: filter.emailVerified, + }; + } + + if (filter.custom_permissions) { + const searchTerms = filter.custom_permissions.split('|'); + + include = [ + { + model: db.permissions, + as: 'custom_permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.users.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('users', 'firstName', query), + ], + }; + } + + const records = await db.users.findAll({ + attributes: ['id', 'firstName'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['firstName', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.firstName, + })); + } + + static async createFromAuth(data, options) { + const transaction = (options && options.transaction) || undefined; + const users = await db.users.create( + { + email: data.email, + firstName: data.firstName, + authenticationUid: data.authenticationUid, + password: data.password, + }, + { transaction }, + ); + + const app_role = await db.roles.findOne({ + where: { name: 'User' }, + }); + if (app_role?.id) { + await users.setApp_role(app_role?.id || null, { + transaction, + }); + } + + await users.update( + { + authenticationUid: users.id, + }, + { transaction }, + ); + + delete users.password; + return users; + } + + static async updatePassword(id, password, options) { + const currentUser = (options && options.currentUser) || { id: null }; + + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, { + transaction, + }); + + await users.update( + { + password, + authenticationUid: id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return users; + } + + static async generateEmailVerificationToken(email, options) { + return this._generateToken( + ['emailVerificationToken', 'emailVerificationTokenExpiresAt'], + email, + options, + ); + } + + static async generatePasswordResetToken(email, options) { + return this._generateToken( + ['passwordResetToken', 'passwordResetTokenExpiresAt'], + email, + options, + ); + } + + static async findByPasswordResetToken(token, options) { + const transaction = (options && options.transaction) || undefined; + + return db.users.findOne( + { + where: { + passwordResetToken: token, + passwordResetTokenExpiresAt: { + [db.Sequelize.Op.gt]: Date.now(), + }, + }, + }, + { transaction }, + ); + } + + static async findByEmailVerificationToken(token, options) { + const transaction = (options && options.transaction) || undefined; + return db.users.findOne( + { + where: { + emailVerificationToken: token, + emailVerificationTokenExpiresAt: { + [db.Sequelize.Op.gt]: Date.now(), + }, + }, + }, + { transaction }, + ); + } + + static async markEmailVerified(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, { + transaction, + }); + + await users.update( + { + emailVerified: true, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return true; + } + + static async _generateToken(keyNames, email, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const users = await db.users.findOne( + { + where: { email: email.toLowerCase() }, + }, + { + transaction, + }, + ); + + const token = crypto.randomBytes(20).toString('hex'); + const tokenExpiresAt = Date.now() + 360000; + + if (users) { + await users.update( + { + [keyNames[0]]: token, + [keyNames[1]]: tokenExpiresAt, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + return token; + } +}; diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js new file mode 100644 index 0000000..567349e --- /dev/null +++ b/backend/src/db/db.config.js @@ -0,0 +1,31 @@ +module.exports = { + production: { + dialect: 'postgres', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + logging: console.log, + seederStorage: 'sequelize', + }, + development: { + username: 'postgres', + dialect: 'postgres', + password: '', + database: 'db_mental_health_wellbing_app', + host: process.env.DB_HOST || 'localhost', + logging: console.log, + seederStorage: 'sequelize', + }, + dev_stage: { + dialect: 'postgres', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + logging: console.log, + seederStorage: 'sequelize', + }, +}; diff --git a/backend/src/db/migrations/1741528731791.js b/backend/src/db/migrations/1741528731791.js new file mode 100644 index 0000000..583ded9 --- /dev/null +++ b/backend/src/db/migrations/1741528731791.js @@ -0,0 +1,782 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'users', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'administrators', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'clients', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'resources', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'sessions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'supervisors', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'therapists', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'roles', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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( + 'permissions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + 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.addColumn( + 'users', + 'firstName', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'lastName', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'phoneNumber', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'email', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'disabled', + { + type: Sequelize.DataTypes.BOOLEAN, + + defaultValue: false, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'password', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerified', + { + type: Sequelize.DataTypes.BOOLEAN, + + defaultValue: false, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerificationToken', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerificationTokenExpiresAt', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'passwordResetToken', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'passwordResetTokenExpiresAt', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'provider', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'administrators', + 'userId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'users', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'administrators', + 'role', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'userId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'users', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'mood', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'resources', + 'title', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'resources', + 'content', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'resources', + 'type', + { + type: Sequelize.DataTypes.ENUM, + + values: ['meditation', 'article', 'video'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'sessions', + 'therapistId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'therapists', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'sessions', + 'clientId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'clients', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'sessions', + 'start_time', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'sessions', + 'end_time', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'sessions', + 'notes', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'supervisors', + 'userId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'users', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'therapists', + 'userId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'users', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'therapists', + 'specialization', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'permissions', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'roles', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'roles', + 'role_customization', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'app_roleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'roles', + key: 'id', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('users', 'app_roleId', { transaction }); + + await queryInterface.removeColumn('roles', 'role_customization', { + transaction, + }); + + await queryInterface.removeColumn('roles', 'name', { transaction }); + + await queryInterface.removeColumn('permissions', 'name', { transaction }); + + await queryInterface.removeColumn('therapists', 'specialization', { + transaction, + }); + + await queryInterface.removeColumn('therapists', 'userId', { + transaction, + }); + + await queryInterface.removeColumn('supervisors', 'userId', { + transaction, + }); + + await queryInterface.removeColumn('sessions', 'notes', { transaction }); + + await queryInterface.removeColumn('sessions', 'end_time', { + transaction, + }); + + await queryInterface.removeColumn('sessions', 'start_time', { + transaction, + }); + + await queryInterface.removeColumn('sessions', 'clientId', { + transaction, + }); + + await queryInterface.removeColumn('sessions', 'therapistId', { + transaction, + }); + + await queryInterface.removeColumn('resources', 'type', { transaction }); + + await queryInterface.removeColumn('resources', 'content', { + transaction, + }); + + await queryInterface.removeColumn('resources', 'title', { transaction }); + + await queryInterface.removeColumn('clients', 'mood', { transaction }); + + await queryInterface.removeColumn('clients', 'userId', { transaction }); + + await queryInterface.removeColumn('administrators', 'role', { + transaction, + }); + + await queryInterface.removeColumn('administrators', 'userId', { + transaction, + }); + + await queryInterface.removeColumn('users', 'provider', { transaction }); + + await queryInterface.removeColumn( + 'users', + 'passwordResetTokenExpiresAt', + { transaction }, + ); + + await queryInterface.removeColumn('users', 'passwordResetToken', { + transaction, + }); + + await queryInterface.removeColumn( + 'users', + 'emailVerificationTokenExpiresAt', + { transaction }, + ); + + await queryInterface.removeColumn('users', 'emailVerificationToken', { + transaction, + }); + + await queryInterface.removeColumn('users', 'emailVerified', { + transaction, + }); + + await queryInterface.removeColumn('users', 'password', { transaction }); + + await queryInterface.removeColumn('users', 'disabled', { transaction }); + + await queryInterface.removeColumn('users', 'email', { transaction }); + + await queryInterface.removeColumn('users', 'phoneNumber', { + transaction, + }); + + await queryInterface.removeColumn('users', 'lastName', { transaction }); + + await queryInterface.removeColumn('users', 'firstName', { transaction }); + + await queryInterface.dropTable('permissions', { transaction }); + + await queryInterface.dropTable('roles', { transaction }); + + await queryInterface.dropTable('therapists', { transaction }); + + await queryInterface.dropTable('supervisors', { transaction }); + + await queryInterface.dropTable('sessions', { transaction }); + + await queryInterface.dropTable('resources', { transaction }); + + await queryInterface.dropTable('clients', { transaction }); + + await queryInterface.dropTable('administrators', { transaction }); + + await queryInterface.dropTable('users', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/administrators.js b/backend/src/db/models/administrators.js new file mode 100644 index 0000000..00e0d48 --- /dev/null +++ b/backend/src/db/models/administrators.js @@ -0,0 +1,57 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const administrators = sequelize.define( + 'administrators', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + role: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + administrators.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.administrators.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.administrators.belongsTo(db.users, { + as: 'createdBy', + }); + + db.administrators.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return administrators; +}; diff --git a/backend/src/db/models/clients.js b/backend/src/db/models/clients.js new file mode 100644 index 0000000..84a4385 --- /dev/null +++ b/backend/src/db/models/clients.js @@ -0,0 +1,83 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const clients = sequelize.define( + 'clients', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + mood: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + clients.associate = (db) => { + db.clients.belongsToMany(db.sessions, { + as: 'sessions', + foreignKey: { + name: 'clients_sessionsId', + }, + constraints: false, + through: 'clientsSessionsSessions', + }); + + db.clients.belongsToMany(db.sessions, { + as: 'sessions_filter', + foreignKey: { + name: 'clients_sessionsId', + }, + constraints: false, + through: 'clientsSessionsSessions', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.clients.hasMany(db.sessions, { + as: 'sessions_client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + //end loop + + db.clients.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.clients.belongsTo(db.users, { + as: 'createdBy', + }); + + db.clients.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return clients; +}; diff --git a/backend/src/db/models/file.js b/backend/src/db/models/file.js new file mode 100644 index 0000000..84ee670 --- /dev/null +++ b/backend/src/db/models/file.js @@ -0,0 +1,53 @@ +module.exports = function (sequelize, DataTypes) { + const file = sequelize.define( + 'file', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + belongsTo: DataTypes.STRING(255), + belongsToId: DataTypes.UUID, + belongsToColumn: DataTypes.STRING(255), + name: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + sizeInBytes: { + type: DataTypes.INTEGER, + allowNull: true, + }, + privateUrl: { + type: DataTypes.STRING(2083), + allowNull: true, + }, + publicUrl: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + }, + { + timestamps: true, + paranoid: true, + }, + ); + + file.associate = (db) => { + db.file.belongsTo(db.users, { + as: 'createdBy', + }); + + db.file.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return file; +}; diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js new file mode 100644 index 0000000..e326416 --- /dev/null +++ b/backend/src/db/models/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const Sequelize = require('sequelize'); +const basename = path.basename(__filename); +const env = process.env.NODE_ENV || 'development'; +const config = require('../db.config')[env]; +const db = {}; + +let sequelize; +console.log(env); +if (config.use_env_variable) { + sequelize = new Sequelize(process.env[config.use_env_variable], config); +} else { + sequelize = new Sequelize( + config.database, + config.username, + config.password, + config, + ); +} + +fs.readdirSync(__dirname) + .filter((file) => { + return ( + file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js' + ); + }) + .forEach((file) => { + const model = require(path.join(__dirname, file))( + sequelize, + Sequelize.DataTypes, + ); + db[model.name] = model; + }); + +Object.keys(db).forEach((modelName) => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); + +db.sequelize = sequelize; +db.Sequelize = Sequelize; + +module.exports = db; diff --git a/backend/src/db/models/permissions.js b/backend/src/db/models/permissions.js new file mode 100644 index 0000000..d647c73 --- /dev/null +++ b/backend/src/db/models/permissions.js @@ -0,0 +1,49 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const permissions = sequelize.define( + 'permissions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + permissions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.permissions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.permissions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return permissions; +}; diff --git a/backend/src/db/models/resources.js b/backend/src/db/models/resources.js new file mode 100644 index 0000000..8e6823e --- /dev/null +++ b/backend/src/db/models/resources.js @@ -0,0 +1,59 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const resources = sequelize.define( + 'resources', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + title: { + type: DataTypes.TEXT, + }, + + content: { + type: DataTypes.TEXT, + }, + + type: { + type: DataTypes.ENUM, + + values: ['meditation', 'article', 'video'], + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + resources.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.resources.belongsTo(db.users, { + as: 'createdBy', + }); + + db.resources.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return resources; +}; diff --git a/backend/src/db/models/roles.js b/backend/src/db/models/roles.js new file mode 100644 index 0000000..0ff5736 --- /dev/null +++ b/backend/src/db/models/roles.js @@ -0,0 +1,79 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const roles = sequelize.define( + 'roles', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + role_customization: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + roles.associate = (db) => { + db.roles.belongsToMany(db.permissions, { + as: 'permissions', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + db.roles.belongsToMany(db.permissions, { + as: 'permissions_filter', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.roles.hasMany(db.users, { + as: 'users_app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + //end loop + + db.roles.belongsTo(db.users, { + as: 'createdBy', + }); + + db.roles.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return roles; +}; diff --git a/backend/src/db/models/sessions.js b/backend/src/db/models/sessions.js new file mode 100644 index 0000000..4f2cfaf --- /dev/null +++ b/backend/src/db/models/sessions.js @@ -0,0 +1,73 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const sessions = sequelize.define( + 'sessions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + start_time: { + type: DataTypes.DATE, + }, + + end_time: { + type: DataTypes.DATE, + }, + + notes: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + sessions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.sessions.belongsTo(db.therapists, { + as: 'therapist', + foreignKey: { + name: 'therapistId', + }, + constraints: false, + }); + + db.sessions.belongsTo(db.clients, { + as: 'client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + db.sessions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.sessions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return sessions; +}; diff --git a/backend/src/db/models/supervisors.js b/backend/src/db/models/supervisors.js new file mode 100644 index 0000000..da417cd --- /dev/null +++ b/backend/src/db/models/supervisors.js @@ -0,0 +1,71 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const supervisors = sequelize.define( + 'supervisors', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + supervisors.associate = (db) => { + db.supervisors.belongsToMany(db.therapists, { + as: 'therapists', + foreignKey: { + name: 'supervisors_therapistsId', + }, + constraints: false, + through: 'supervisorsTherapistsTherapists', + }); + + db.supervisors.belongsToMany(db.therapists, { + as: 'therapists_filter', + foreignKey: { + name: 'supervisors_therapistsId', + }, + constraints: false, + through: 'supervisorsTherapistsTherapists', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.supervisors.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.supervisors.belongsTo(db.users, { + as: 'createdBy', + }); + + db.supervisors.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return supervisors; +}; diff --git a/backend/src/db/models/therapists.js b/backend/src/db/models/therapists.js new file mode 100644 index 0000000..ff3f1ce --- /dev/null +++ b/backend/src/db/models/therapists.js @@ -0,0 +1,65 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const therapists = sequelize.define( + 'therapists', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + specialization: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + therapists.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.therapists.hasMany(db.sessions, { + as: 'sessions_therapist', + foreignKey: { + name: 'therapistId', + }, + constraints: false, + }); + + //end loop + + db.therapists.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.therapists.belongsTo(db.users, { + as: 'createdBy', + }); + + db.therapists.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return therapists; +}; diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js new file mode 100644 index 0000000..a1d5889 --- /dev/null +++ b/backend/src/db/models/users.js @@ -0,0 +1,203 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const users = sequelize.define( + 'users', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + firstName: { + type: DataTypes.TEXT, + }, + + lastName: { + type: DataTypes.TEXT, + }, + + phoneNumber: { + type: DataTypes.TEXT, + }, + + email: { + type: DataTypes.TEXT, + }, + + disabled: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + password: { + type: DataTypes.TEXT, + }, + + emailVerified: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + emailVerificationToken: { + type: DataTypes.TEXT, + }, + + emailVerificationTokenExpiresAt: { + type: DataTypes.DATE, + }, + + passwordResetToken: { + type: DataTypes.TEXT, + }, + + passwordResetTokenExpiresAt: { + type: DataTypes.DATE, + }, + + provider: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + users.associate = (db) => { + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions_filter', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.users.hasMany(db.administrators, { + as: 'administrators_user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.users.hasMany(db.clients, { + as: 'clients_user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.users.hasMany(db.supervisors, { + as: 'supervisors_user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.users.hasMany(db.therapists, { + as: 'therapists_user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + //end loop + + db.users.belongsTo(db.roles, { + as: 'app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + db.users.hasMany(db.file, { + as: 'avatar', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + }, + }); + + db.users.belongsTo(db.users, { + as: 'createdBy', + }); + + db.users.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + users.beforeCreate((users, options) => { + users = trimStringFields(users); + + if ( + users.provider !== providers.LOCAL && + Object.values(providers).indexOf(users.provider) > -1 + ) { + users.emailVerified = true; + + if (!users.password) { + const password = crypto.randomBytes(20).toString('hex'); + + const hashedPassword = bcrypt.hashSync( + password, + config.bcrypt.saltRounds, + ); + + users.password = hashedPassword; + } + } + }); + + users.beforeUpdate((users, options) => { + users = trimStringFields(users); + }); + + return users; +}; + +function trimStringFields(users) { + users.email = users.email.trim(); + + users.firstName = users.firstName ? users.firstName.trim() : null; + + users.lastName = users.lastName ? users.lastName.trim() : null; + + return users; +} diff --git a/backend/src/db/reset.js b/backend/src/db/reset.js new file mode 100644 index 0000000..bc0b5f9 --- /dev/null +++ b/backend/src/db/reset.js @@ -0,0 +1,16 @@ +const db = require('./models'); +const { execSync } = require('child_process'); + +console.log('Resetting Database'); + +db.sequelize + .sync({ force: true }) + .then(() => { + execSync('sequelize db:seed:all'); + console.log('OK'); + process.exit(); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/backend/src/db/seeders/20200430130759-admin-user.js b/backend/src/db/seeders/20200430130759-admin-user.js new file mode 100644 index 0000000..fccd771 --- /dev/null +++ b/backend/src/db/seeders/20200430130759-admin-user.js @@ -0,0 +1,69 @@ +'use strict'; +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const ids = [ + '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af', + 'af5a87be-8f9c-4630-902a-37a60b7005ba', + '5bc531ab-611f-41f3-9373-b7cc5d09c93d', +]; + +module.exports = { + up: async (queryInterface, Sequelize) => { + let hash = bcrypt.hashSync(config.admin_pass, config.bcrypt.saltRounds); + + try { + await queryInterface.bulkInsert('users', [ + { + id: ids[0], + firstName: 'Admin', + email: config.admin_email, + emailVerified: true, + provider: config.providers.LOCAL, + password: hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: ids[1], + firstName: 'John', + email: 'john@doe.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: ids[2], + firstName: 'Client', + email: 'client@hello.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + } catch (error) { + console.error('Error during bulkInsert:', error); + throw error; + } + }, + down: async (queryInterface, Sequelize) => { + try { + await queryInterface.bulkDelete( + 'users', + { + id: { + [Sequelize.Op.in]: ids, + }, + }, + {}, + ); + } catch (error) { + console.error('Error during bulkDelete:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js new file mode 100644 index 0000000..8a93a3a --- /dev/null +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -0,0 +1,934 @@ +const { v4: uuid } = require('uuid'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + await queryInterface.bulkInsert('roles', [ + { + id: getId('Administrator'), + name: 'Administrator', + createdAt, + updatedAt, + }, + + { + id: getId('PlatformManager'), + name: 'Platform Manager', + createdAt, + updatedAt, + }, + + { + id: getId('SystemCoordinator'), + name: 'System Coordinator', + createdAt, + updatedAt, + }, + + { + id: getId('OperationsLead'), + name: 'Operations Lead', + createdAt, + updatedAt, + }, + + { + id: getId('SupportSupervisor'), + name: 'Support Supervisor', + createdAt, + updatedAt, + }, + + { + id: getId('TechnicalDirector'), + name: 'Technical Director', + createdAt, + updatedAt, + }, + ]); + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = [ + 'users', + 'administrators', + 'clients', + 'resources', + 'sessions', + 'supervisors', + 'therapists', + 'roles', + 'permissions', + , + ]; + await queryInterface.bulkInsert( + 'permissions', + entities.flatMap(createPermissions), + ); + await queryInterface.bulkInsert('permissions', [ + { + id: getId(`READ_API_DOCS`), + createdAt, + updatedAt, + name: `READ_API_DOCS`, + }, + ]); + await queryInterface.bulkInsert('permissions', [ + { + id: getId(`CREATE_SEARCH`), + createdAt, + updatedAt, + name: `CREATE_SEARCH`, + }, + ]); + + await queryInterface.sequelize + .query(`create table "rolesPermissionsPermissions" +( +"createdAt" timestamp with time zone not null, +"updatedAt" timestamp with time zone not null, +"roles_permissionsId" uuid not null, +"permissionId" uuid not null, +primary key ("roles_permissionsId", "permissionId") +);`); + + await queryInterface.bulkInsert('rolesPermissionsPermissions', [ + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('READ_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('UPDATE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('DELETE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('READ_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('UPDATE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('DELETE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('UPDATE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('DELETE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('DELETE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('PlatformManager'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SystemCoordinator'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsLead'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SupportSupervisor'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalDirector'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_ADMINISTRATORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_ADMINISTRATORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_ADMINISTRATORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_ADMINISTRATORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_RESOURCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_RESOURCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_RESOURCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_RESOURCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_SESSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_SESSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_SESSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_SESSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_SUPERVISORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_SUPERVISORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_SUPERVISORS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_SUPERVISORS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_THERAPISTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_THERAPISTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_THERAPISTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_THERAPISTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_ROLES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_PERMISSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_API_DOCS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_SEARCH'), + }, + ]); + + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'SuperAdmin', + )}' WHERE "email"='super_admin@flatlogic.com'`, + ); + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'Administrator', + )}' WHERE "email"='admin@flatlogic.com'`, + ); + + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'PlatformManager', + )}' WHERE "email"='client@hello.com'`, + ); + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'SystemCoordinator', + )}' WHERE "email"='john@doe.com'`, + ); + }, +}; diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js new file mode 100644 index 0000000..758e268 --- /dev/null +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -0,0 +1,427 @@ +const db = require('../models'); +const Users = db.users; + +const Administrators = db.administrators; + +const Clients = db.clients; + +const Resources = db.resources; + +const Sessions = db.sessions; + +const Supervisors = db.supervisors; + +const Therapists = db.therapists; + +const AdministratorsData = [ + { + // type code here for "relation_one" field + + role: 'Platform Manager', + }, + + { + // type code here for "relation_one" field + + role: 'System Coordinator', + }, + + { + // type code here for "relation_one" field + + role: 'Operations Lead', + }, +]; + +const ClientsData = [ + { + // type code here for "relation_one" field + + mood: 'Happy', + + // type code here for "relation_many" field + }, + + { + // type code here for "relation_one" field + + mood: 'Anxious', + + // type code here for "relation_many" field + }, + + { + // type code here for "relation_one" field + + mood: 'Calm', + + // type code here for "relation_many" field + }, +]; + +const ResourcesData = [ + { + title: 'Guided Meditation for Relaxation', + + content: 'A guided meditation session to help you relax and unwind.', + + type: 'meditation', + }, + + { + title: 'Understanding Anxiety', + + content: 'An article explaining the causes and symptoms of anxiety.', + + type: 'meditation', + }, + + { + title: 'Mindfulness Techniques', + + content: 'A video tutorial on practicing mindfulness.', + + type: 'article', + }, +]; + +const SessionsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_time: new Date('2023-10-01T10:00:00Z'), + + end_time: new Date('2023-10-01T11:00:00Z'), + + notes: 'Discussed progress and set new goals.', + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_time: new Date('2023-10-08T10:00:00Z'), + + end_time: new Date('2023-10-08T11:00:00Z'), + + notes: 'Reviewed coping strategies.', + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_time: new Date('2023-10-02T14:00:00Z'), + + end_time: new Date('2023-10-02T15:00:00Z'), + + notes: 'Explored family dynamics.', + }, +]; + +const SupervisorsData = [ + { + // type code here for "relation_one" field + // type code here for "relation_many" field + }, + + { + // type code here for "relation_one" field + // type code here for "relation_many" field + }, + + { + // type code here for "relation_one" field + // type code here for "relation_many" field + }, +]; + +const TherapistsData = [ + { + // type code here for "relation_one" field + + specialization: 'Cognitive Behavioral Therapy', + }, + + { + // type code here for "relation_one" field + + specialization: 'Family Therapy', + }, + + { + // type code here for "relation_one" field + + specialization: 'Art Therapy', + }, +]; + +// Similar logic for "relation_many" + +async function associateAdministratorWithUser() { + const relatedUser0 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Administrator0 = await Administrators.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Administrator0?.setUser) { + await Administrator0.setUser(relatedUser0); + } + + const relatedUser1 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Administrator1 = await Administrators.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Administrator1?.setUser) { + await Administrator1.setUser(relatedUser1); + } + + const relatedUser2 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Administrator2 = await Administrators.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Administrator2?.setUser) { + await Administrator2.setUser(relatedUser2); + } +} + +async function associateClientWithUser() { + const relatedUser0 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Client0 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Client0?.setUser) { + await Client0.setUser(relatedUser0); + } + + const relatedUser1 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Client1 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Client1?.setUser) { + await Client1.setUser(relatedUser1); + } + + const relatedUser2 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Client2 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Client2?.setUser) { + await Client2.setUser(relatedUser2); + } +} + +// Similar logic for "relation_many" + +async function associateSessionWithTherapist() { + const relatedTherapist0 = await Therapists.findOne({ + offset: Math.floor(Math.random() * (await Therapists.count())), + }); + const Session0 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Session0?.setTherapist) { + await Session0.setTherapist(relatedTherapist0); + } + + const relatedTherapist1 = await Therapists.findOne({ + offset: Math.floor(Math.random() * (await Therapists.count())), + }); + const Session1 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Session1?.setTherapist) { + await Session1.setTherapist(relatedTherapist1); + } + + const relatedTherapist2 = await Therapists.findOne({ + offset: Math.floor(Math.random() * (await Therapists.count())), + }); + const Session2 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Session2?.setTherapist) { + await Session2.setTherapist(relatedTherapist2); + } +} + +async function associateSessionWithClient() { + const relatedClient0 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Session0 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Session0?.setClient) { + await Session0.setClient(relatedClient0); + } + + const relatedClient1 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Session1 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Session1?.setClient) { + await Session1.setClient(relatedClient1); + } + + const relatedClient2 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Session2 = await Sessions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Session2?.setClient) { + await Session2.setClient(relatedClient2); + } +} + +async function associateSupervisorWithUser() { + const relatedUser0 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Supervisor0 = await Supervisors.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Supervisor0?.setUser) { + await Supervisor0.setUser(relatedUser0); + } + + const relatedUser1 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Supervisor1 = await Supervisors.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Supervisor1?.setUser) { + await Supervisor1.setUser(relatedUser1); + } + + const relatedUser2 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Supervisor2 = await Supervisors.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Supervisor2?.setUser) { + await Supervisor2.setUser(relatedUser2); + } +} + +// Similar logic for "relation_many" + +async function associateTherapistWithUser() { + const relatedUser0 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Therapist0 = await Therapists.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Therapist0?.setUser) { + await Therapist0.setUser(relatedUser0); + } + + const relatedUser1 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Therapist1 = await Therapists.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Therapist1?.setUser) { + await Therapist1.setUser(relatedUser1); + } + + const relatedUser2 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Therapist2 = await Therapists.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Therapist2?.setUser) { + await Therapist2.setUser(relatedUser2); + } +} + +module.exports = { + up: async (queryInterface, Sequelize) => { + await Administrators.bulkCreate(AdministratorsData); + + await Clients.bulkCreate(ClientsData); + + await Resources.bulkCreate(ResourcesData); + + await Sessions.bulkCreate(SessionsData); + + await Supervisors.bulkCreate(SupervisorsData); + + await Therapists.bulkCreate(TherapistsData); + + await Promise.all([ + // Similar logic for "relation_many" + + await associateAdministratorWithUser(), + + await associateClientWithUser(), + + // Similar logic for "relation_many" + + await associateSessionWithTherapist(), + + await associateSessionWithClient(), + + await associateSupervisorWithUser(), + + // Similar logic for "relation_many" + + await associateTherapistWithUser(), + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('administrators', null, {}); + + await queryInterface.bulkDelete('clients', null, {}); + + await queryInterface.bulkDelete('resources', null, {}); + + await queryInterface.bulkDelete('sessions', null, {}); + + await queryInterface.bulkDelete('supervisors', null, {}); + + await queryInterface.bulkDelete('therapists', null, {}); + }, +}; diff --git a/backend/src/db/utils.js b/backend/src/db/utils.js new file mode 100644 index 0000000..c257f8d --- /dev/null +++ b/backend/src/db/utils.js @@ -0,0 +1,24 @@ +const validator = require('validator'); +const { v4: uuid } = require('uuid'); +const Sequelize = require('./models').Sequelize; + +module.exports = class Utils { + static uuid(value) { + let id = value; + + if (!validator.isUUID(id)) { + id = uuid(); + } + + return id; + } + + static ilike(model, column, value) { + return Sequelize.where( + Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)), + { + [Sequelize.Op.like]: `%${value}%`.toLowerCase(), + }, + ); + } +}; diff --git a/backend/src/helpers.js b/backend/src/helpers.js new file mode 100644 index 0000000..1d918b5 --- /dev/null +++ b/backend/src/helpers.js @@ -0,0 +1,23 @@ +const jwt = require('jsonwebtoken'); +const config = require('./config'); + +module.exports = class Helpers { + static wrapAsync(fn) { + return function (req, res, next) { + fn(req, res, next).catch(next); + }; + } + + static commonErrorHandler(error, req, res, next) { + if ([400, 403, 404].includes(error.code)) { + return res.status(error.code).send(error.message); + } + + console.error(error); + return res.status(500).send(error.message); + } + + static jwtSign(data) { + return jwt.sign(data, config.secret_key, { expiresIn: '6h' }); + } +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..f47426a --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,185 @@ +const express = require('express'); +const cors = require('cors'); +const app = express(); +const passport = require('passport'); +const path = require('path'); +const fs = require('fs'); +const bodyParser = require('body-parser'); +const db = require('./db/models'); +const config = require('./config'); +const swaggerUI = require('swagger-ui-express'); +const swaggerJsDoc = require('swagger-jsdoc'); + +const authRoutes = require('./routes/auth'); +const fileRoutes = require('./routes/file'); +const searchRoutes = require('./routes/search'); +const pexelsRoutes = require('./routes/pexels'); + +const openaiRoutes = require('./routes/openai'); + +const contactFormRoutes = require('./routes/contactForm'); + +const usersRoutes = require('./routes/users'); + +const administratorsRoutes = require('./routes/administrators'); + +const clientsRoutes = require('./routes/clients'); + +const resourcesRoutes = require('./routes/resources'); + +const sessionsRoutes = require('./routes/sessions'); + +const supervisorsRoutes = require('./routes/supervisors'); + +const therapistsRoutes = require('./routes/therapists'); + +const rolesRoutes = require('./routes/roles'); + +const permissionsRoutes = require('./routes/permissions'); + +const options = { + definition: { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'mental health wellbing app', + description: + 'mental health wellbing app Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', + }, + servers: [ + { + url: config.swaggerUrl, + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + responses: { + UnauthorizedError: { + description: 'Access token is missing or invalid', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: ['./src/routes/*.js'], +}; + +const specs = swaggerJsDoc(options); +app.use( + '/api-docs', + function (req, res, next) { + swaggerUI.host = req.get('host'); + next(); + }, + swaggerUI.serve, + swaggerUI.setup(specs), +); + +app.use(cors({ origin: true })); +require('./auth/auth'); + +app.use(bodyParser.json()); + +app.use('/api/auth', authRoutes); +app.use('/api/file', fileRoutes); +app.use('/api/pexels', pexelsRoutes); +app.enable('trust proxy'); + +app.use( + '/api/users', + passport.authenticate('jwt', { session: false }), + usersRoutes, +); + +app.use( + '/api/administrators', + passport.authenticate('jwt', { session: false }), + administratorsRoutes, +); + +app.use( + '/api/clients', + passport.authenticate('jwt', { session: false }), + clientsRoutes, +); + +app.use( + '/api/resources', + passport.authenticate('jwt', { session: false }), + resourcesRoutes, +); + +app.use( + '/api/sessions', + passport.authenticate('jwt', { session: false }), + sessionsRoutes, +); + +app.use( + '/api/supervisors', + passport.authenticate('jwt', { session: false }), + supervisorsRoutes, +); + +app.use( + '/api/therapists', + passport.authenticate('jwt', { session: false }), + therapistsRoutes, +); + +app.use( + '/api/roles', + passport.authenticate('jwt', { session: false }), + rolesRoutes, +); + +app.use( + '/api/permissions', + passport.authenticate('jwt', { session: false }), + permissionsRoutes, +); + +app.use( + '/api/openai', + passport.authenticate('jwt', { session: false }), + openaiRoutes, +); + +app.use('/api/contact-form', contactFormRoutes); + +app.use( + '/api/search', + passport.authenticate('jwt', { session: false }), + searchRoutes, +); + +const publicDir = path.join(__dirname, '../public'); + +if (fs.existsSync(publicDir)) { + app.use('/', express.static(publicDir)); + + app.get('*', function (request, response) { + response.sendFile(path.resolve(publicDir, 'index.html')); + }); +} + +const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; + +db.sequelize.sync().then(function () { + app.listen(PORT, () => { + console.log(`Listening on port ${PORT}`); + }); +}); + +module.exports = app; diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js new file mode 100644 index 0000000..2675691 --- /dev/null +++ b/backend/src/middlewares/check-permissions.js @@ -0,0 +1,64 @@ +const ValidationError = require('../services/notifications/errors/validation'); + +/** + * @param {string} permission + * @return {import("express").RequestHandler} + */ +function checkPermissions(permission) { + return (req, res, next) => { + const { currentUser } = req; + if (currentUser) { + if (currentUser.id === req.params.id || currentUser.id === req.body.id) { + next(); + return; + } + const userPermission = currentUser.custom_permissions.find( + (cp) => cp.name === permission, + ); + + if (userPermission) { + next(); + } else { + if (!currentUser.app_role) { + return next(new ValidationError('auth.forbidden')); + } + currentUser.app_role + .getPermissions() + .then((permissions) => { + if (permissions.find((p) => p.name === permission)) { + next(); + } else { + next(new ValidationError('auth.forbidden')); + } + }) + .catch((e) => next(e)); + } + } else { + next(new ValidationError('auth.unauthorized')); + } + }; +} + +const METHOD_MAP = { + POST: 'CREATE', + GET: 'READ', + PUT: 'UPDATE', + PATCH: 'UPDATE', + DELETE: 'DELETE', +}; + +/** + * @param {string} name + * @return {import("express").RequestHandler} + */ +function checkCrudPermissions(name) { + return (req, res, next) => { + const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; + checkPermissions(permissionName)(req, res, next); + }; +} + +module.exports = { + checkPermissions, + checkCrudPermissions, +}; diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js new file mode 100644 index 0000000..6d88c73 --- /dev/null +++ b/backend/src/middlewares/upload.js @@ -0,0 +1,11 @@ +const util = require('util'); +const Multer = require('multer'); +const maxSize = 10 * 1024 * 1024; + +let processFile = Multer({ + storage: Multer.memoryStorage(), + limits: { fileSize: maxSize }, +}).single('file'); + +let processFileMiddleware = util.promisify(processFile); +module.exports = processFileMiddleware; diff --git a/backend/src/routes/administrators.js b/backend/src/routes/administrators.js new file mode 100644 index 0000000..116db61 --- /dev/null +++ b/backend/src/routes/administrators.js @@ -0,0 +1,444 @@ +const express = require('express'); + +const AdministratorsService = require('../services/administrators'); +const AdministratorsDBApi = require('../db/api/administrators'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('administrators')); + +/** + * @swagger + * components: + * schemas: + * Administrators: + * type: object + * properties: + + * role: + * type: string + * default: role + + */ + +/** + * @swagger + * tags: + * name: Administrators + * description: The Administrators managing API + */ + +/** + * @swagger + * /api/administrators: + * post: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Administrators" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await AdministratorsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Administrators" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await AdministratorsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/administrators/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Administrators" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await AdministratorsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/administrators/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await AdministratorsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/administrators/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await AdministratorsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/administrators: + * get: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Get all administrators + * description: Get all administrators + * responses: + * 200: + * description: Administrators list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await AdministratorsDBApi.findAll(req.query, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'role']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/administrators/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Count all administrators + * description: Count all administrators + * responses: + * 200: + * description: Administrators count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await AdministratorsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/administrators/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Find all administrators that match search criteria + * description: Find all administrators that match search criteria + * responses: + * 200: + * description: Administrators list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Administrators" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await AdministratorsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/administrators/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Administrators] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Administrators" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await AdministratorsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..b4edb29 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,268 @@ +const express = require('express'); +const passport = require('passport'); + +const config = require('../config'); +const AuthService = require('../services/auth'); +const ForbiddenError = require('../services/notifications/errors/forbidden'); +const EmailSender = require('../services/email'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Auth: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * default: admin@flatlogic.com + * description: User email + * password: + * type: string + * default: password + * description: User password + */ + +/** + * @swagger + * tags: + * name: Auth + * description: Authorization operations + */ + +/** + * @swagger + * /api/auth/signin/local: + * post: + * tags: [Auth] + * summary: Logs user into the system + * description: Logs user into the system + * requestBody: + * description: Set valid user email and password + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Auth" + * responses: + * 200: + * description: Successful login + * 400: + * description: Invalid username/password supplied + * x-codegen-request-body-name: body + */ + +router.post( + '/signin/local', + wrapAsync(async (req, res) => { + const payload = await AuthService.signin( + req.body.email, + req.body.password, + req, + ); + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/auth/me: + * get: + * security: + * - bearerAuth: [] + * tags: [Auth] + * summary: Get current authorized user info + * description: Get current authorized user info + * responses: + * 200: + * description: Successful retrieval of current authorized user data + * 400: + * description: Invalid username/password supplied + * x-codegen-request-body-name: body + */ + +router.get( + '/me', + passport.authenticate('jwt', { session: false }), + (req, res) => { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + + const payload = req.currentUser; + delete payload.password; + res.status(200).send(payload); + }, +); + +router.put( + '/password-reset', + wrapAsync(async (req, res) => { + const payload = await AuthService.passwordReset( + req.body.token, + req.body.password, + req, + ); + res.status(200).send(payload); + }), +); + +router.put( + '/password-update', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + const payload = await AuthService.passwordUpdate( + req.body.currentPassword, + req.body.newPassword, + req, + ); + res.status(200).send(payload); + }), +); + +router.post( + '/send-email-address-verification-email', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + if (!req.currentUser) { + throw new ForbiddenError(); + } + + await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); + const payload = true; + res.status(200).send(payload); + }), +); + +router.post( + '/send-password-reset-email', + wrapAsync(async (req, res) => { + const link = new URL(req.headers.referer); + await AuthService.sendPasswordResetEmail( + req.body.email, + 'register', + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/auth/signup: + * post: + * tags: [Auth] + * summary: Register new user into the system + * description: Register new user into the system + * requestBody: + * description: Set valid user email and password + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Auth" + * responses: + * 200: + * description: New user successfully signed up + * 400: + * description: Invalid username/password supplied + * 500: + * description: Some server error + * x-codegen-request-body-name: body + */ + +router.post( + '/signup', + wrapAsync(async (req, res) => { + const link = new URL(req.headers.referer); + const payload = await AuthService.signup( + req.body.email, + req.body.password, + + req, + link.host, + ); + res.status(200).send(payload); + }), +); + +router.put( + '/profile', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + + await AuthService.updateProfile(req.body.profile, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +router.put( + '/verify-email', + wrapAsync(async (req, res) => { + const payload = await AuthService.verifyEmail( + req.body.token, + req, + req.headers.referer, + ); + res.status(200).send(payload); + }), +); + +router.get('/email-configured', (req, res) => { + const payload = EmailSender.isConfigured; + res.status(200).send(payload); +}); + +router.get('/signin/google', (req, res, next) => { + passport.authenticate('google', { + scope: ['profile', 'email'], + state: req.query.app, + })(req, res, next); +}); + +router.get( + '/signin/google/callback', + passport.authenticate('google', { + failureRedirect: '/login', + session: false, + }), + + function (req, res) { + socialRedirect(res, req.query.state, req.user.token, config); + }, +); + +router.get('/signin/microsoft', (req, res, next) => { + passport.authenticate('microsoft', { + scope: ['https://graph.microsoft.com/user.read openid'], + state: req.query.app, + })(req, res, next); +}); + +router.get( + '/signin/microsoft/callback', + passport.authenticate('microsoft', { + failureRedirect: '/login', + session: false, + }), + function (req, res) { + socialRedirect(res, req.query.state, req.user.token, config); + }, +); + +router.use('/', require('../helpers').commonErrorHandler); + +function socialRedirect(res, state, token, config) { + res.redirect(config.uiUrl + '/login?token=' + token); +} + +module.exports = router; diff --git a/backend/src/routes/clients.js b/backend/src/routes/clients.js new file mode 100644 index 0000000..653ee25 --- /dev/null +++ b/backend/src/routes/clients.js @@ -0,0 +1,438 @@ +const express = require('express'); + +const ClientsService = require('../services/clients'); +const ClientsDBApi = require('../db/api/clients'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('clients')); + +/** + * @swagger + * components: + * schemas: + * Clients: + * type: object + * properties: + + * mood: + * type: string + * default: mood + + */ + +/** + * @swagger + * tags: + * name: Clients + * description: The Clients managing API + */ + +/** + * @swagger + * /api/clients: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Clients" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClientsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClientsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Clients" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ClientsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ClientsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ClientsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Get all clients + * description: Get all clients + * responses: + * 200: + * description: Clients list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await ClientsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'mood']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/clients/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Count all clients + * description: Count all clients + * responses: + * 200: + * description: Clients count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await ClientsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Find all clients that match search criteria + * description: Find all clients that match search criteria + * responses: + * 200: + * description: Clients list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await ClientsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/clients/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ClientsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/contactForm.js b/backend/src/routes/contactForm.js new file mode 100644 index 0000000..f405011 --- /dev/null +++ b/backend/src/routes/contactForm.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const EmailSender = require('../services/email'); + +router.post('/send', async (req, res) => { + try { + const { email, subject, message } = req.body; + + if (!email || !subject || !message) { + return res.status(400).json({ error: 'All fields are required' }); + } + + const emailSender = new EmailSender({ + to: 'guruvishnukurra800@gmail.com', + subject: subject, + html: () => ` +

From: ${email}

+

Subject: ${subject}

+

Message:

+

${message}

+ `, + text: () => `From: ${email}\nSubject: ${subject}\nMessage:\n${message}`, + }); + + await emailSender.send(); + res.status(200).json({ message: 'Email sent successfully' }); + } catch (error) { + console.error('Error sending email:', error); + res.status(500).json({ error: 'Error sending email' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js new file mode 100644 index 0000000..e98d04c --- /dev/null +++ b/backend/src/routes/file.js @@ -0,0 +1,40 @@ +const express = require('express'); +const config = require('../config'); +const path = require('path'); +const passport = require('passport'); +const services = require('../services/file'); +const router = express.Router(); + +router.get('/download', (req, res) => { + if ( + process.env.NODE_ENV == 'production' || + process.env.NEXT_PUBLIC_BACK_API + ) { + services.downloadGCloud(req, res); + } else { + services.downloadLocal(req, res); + } +}); + +router.post( + '/upload/:table/:field', + passport.authenticate('jwt', { session: false }), + (req, res) => { + const fileName = `${req.params.table}/${req.params.field}`; + + if ( + process.env.NODE_ENV == 'production' || + process.env.NEXT_PUBLIC_BACK_API + ) { + services.uploadGCloud(fileName, req, res); + } else { + services.uploadLocal(fileName, { + entity: null, + maxFileSize: 10 * 1024 * 1024, + folderIncludesAuthenticationUid: false, + })(req, res); + } + }, +); + +module.exports = router; diff --git a/backend/src/routes/openai.js b/backend/src/routes/openai.js new file mode 100644 index 0000000..341fe0a --- /dev/null +++ b/backend/src/routes/openai.js @@ -0,0 +1,180 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const router = express.Router(); +const sjs = require('sequelize-json-schema'); +const { getWidget } = require('../services/openai'); +const RolesService = require('../services/roles'); +const RolesDBApi = require('../db/api/roles'); + +/** + * @swagger + * /api/roles/roles-info/{infoId}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Remove role information by ID + * description: Remove specific role information by ID + * parameters: + * - in: path + * name: infoId + * description: ID of role information to remove + * required: true + * schema: + * type: string + * - in: query + * name: userId + * description: ID of the user + * required: true + * schema: + * type: string + * - in: query + * name: key + * description: Key of the role information to remove + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Role information successfully removed + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * type: string + * description: The user information + * 400: + * description: Invalid ID or key supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Role not found + * 500: + * description: Some server error + */ + +router.delete( + '/roles-info/:infoId', + wrapAsync(async (req, res) => { + const role = await RolesService.removeRoleInfoById( + req.query.infoId, + req.query.roleId, + req.query.key, + req.currentUser, + ); + + res.status(200).send(role); + }), +); + +/** + * @swagger + * /api/roles/role-info/{roleId}: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get role information by key + * description: Get specific role information by key + * parameters: + * - in: path + * name: roleId + * description: ID of role to get information for + * required: true + * schema: + * type: string + * - in: query + * name: key + * description: Key of the role information to retrieve + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Role information successfully received + * content: + * application/json: + * schema: + * type: object + * properties: + * info: + * type: string + * description: The role information + * 400: + * description: Invalid ID or key supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Role not found + * 500: + * description: Some server error + */ + +router.get( + '/info-by-key', + wrapAsync(async (req, res) => { + const roleId = req.query.roleId; + const key = req.query.key; + const currentUser = req.currentUser; + let info = await RolesService.getRoleInfoByKey(key, roleId, currentUser); + const role = await RolesDBApi.findBy({ id: roleId }); + if (!role?.role_customization) { + await Promise.all( + ['pie', 'bar'].map(async (e) => { + const schema = await sjs.getSequelizeSchema(db.sequelize, {}); + const payload = { + description: `Create some cool ${e} chart`, + modelDefinition: schema.definitions, + }; + const widgetId = await getWidget(payload, currentUser?.id, roleId); + if (widgetId) { + await RolesService.addRoleInfo( + roleId, + currentUser?.id, + 'widgets', + widgetId, + req.currentUser, + ); + } + }), + ); + info = await RolesService.getRoleInfoByKey(key, roleId, currentUser); + } + res.status(200).send(info); + }), +); + +router.post( + '/create_widget', + wrapAsync(async (req, res) => { + const { description, userId, roleId } = req.body; + + const currentUser = req.currentUser; + const schema = await sjs.getSequelizeSchema(db.sequelize, {}); + const payload = { + description, + modelDefinition: schema.definitions, + }; + + const widgetId = await getWidget(payload, userId, roleId); + + if (widgetId) { + await RolesService.addRoleInfo( + roleId, + userId, + 'widgets', + widgetId, + currentUser, + ); + + return res.status(200).send(widgetId); + } else { + return res.status(400).send(widgetId); + } + }), +); + +module.exports = router; diff --git a/backend/src/routes/organizationLogin.js b/backend/src/routes/organizationLogin.js new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js new file mode 100644 index 0000000..c3f1685 --- /dev/null +++ b/backend/src/routes/permissions.js @@ -0,0 +1,442 @@ +const express = require('express'); + +const PermissionsService = require('../services/permissions'); +const PermissionsDBApi = require('../db/api/permissions'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('permissions')); + +/** + * @swagger + * components: + * schemas: + * Permissions: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Permissions + * description: The Permissions managing API + */ + +/** + * @swagger + * /api/permissions: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Permissions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PermissionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PermissionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Permissions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await PermissionsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await PermissionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await PermissionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Get all permissions + * description: Get all permissions + * responses: + * 200: + * description: Permissions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await PermissionsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/permissions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Count all permissions + * description: Count all permissions + * responses: + * 200: + * description: Permissions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await PermissionsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Find all permissions that match search criteria + * description: Find all permissions that match search criteria + * responses: + * 200: + * description: Permissions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await PermissionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/permissions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await PermissionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/pexels.js b/backend/src/routes/pexels.js new file mode 100644 index 0000000..ab2100f --- /dev/null +++ b/backend/src/routes/pexels.js @@ -0,0 +1,106 @@ +const express = require('express'); +const router = express.Router(); +const { pexelsKey, pexelsQuery } = require('../config'); +const fetch = require('node-fetch'); + +const KEY = pexelsKey; + +router.get('/image', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + const query = pexelsQuery || 'nature'; + const orientation = 'portrait'; + const perPage = 1; + const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + + try { + const response = await fetch(url, { headers }); + const data = await response.json(); + res.status(200).json(data.photos[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch image' }); + } +}); + +router.get('/video', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + const query = pexelsQuery || 'nature'; + const orientation = 'portrait'; + const perPage = 1; + const url = `https://api.pexels.com/videos/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + + try { + const response = await fetch(url, { headers }); + const data = await response.json(); + res.status(200).json(data.videos[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch video' }); + } +}); + +router.get('/multiple-images', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + + const queries = req.query.queries + ? req.query.queries.split(',') + : ['home', 'apple', 'pizza', 'mountains', 'cat']; + const orientation = 'square'; + const perPage = 1; + + const fallbackImage = { + src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', + photographer: 'Yan Krukau', + photographer_url: 'https://www.pexels.com/@yankrukov', + }; + const fetchFallbackImage = async () => { + try { + const response = await fetch('https://picsum.photos/600'); + return { + src: response.url, + photographer: 'Random Picsum', + photographer_url: 'https://picsum.photos/', + }; + } catch (error) { + return fallbackImage; + } + }; + const fetchImage = async (query) => { + const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + const response = await fetch(url, { headers }); + const data = await response.json(); + return data.photos[0] || null; + }; + + const imagePromises = queries.map((query) => fetchImage(query)); + const imagesResults = await Promise.allSettled(imagePromises); + + const formattedImages = await Promise.all( + imagesResults.map(async (result) => { + if (result.status === 'fulfilled' && result.value) { + const image = result.value; + return { + src: image.src?.original || fallbackImage.src, + photographer: image.photographer || fallbackImage.photographer, + photographer_url: + image.photographer_url || fallbackImage.photographer_url, + }; + } else { + const fallback = await fetchFallbackImage(); + return { + src: fallback.src || '', + photographer: fallback.photographer || 'Unknown', + photographer_url: fallback.photographer_url || '', + }; + } + }), + ); + + res.json(formattedImages); +}); + +module.exports = router; diff --git a/backend/src/routes/resources.js b/backend/src/routes/resources.js new file mode 100644 index 0000000..1d11858 --- /dev/null +++ b/backend/src/routes/resources.js @@ -0,0 +1,442 @@ +const express = require('express'); + +const ResourcesService = require('../services/resources'); +const ResourcesDBApi = require('../db/api/resources'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('resources')); + +/** + * @swagger + * components: + * schemas: + * Resources: + * type: object + * properties: + + * title: + * type: string + * default: title + * content: + * type: string + * default: content + + * + */ + +/** + * @swagger + * tags: + * name: Resources + * description: The Resources managing API + */ + +/** + * @swagger + * /api/resources: + * post: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Resources" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ResourcesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Resources" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ResourcesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/resources/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Resources" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ResourcesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/resources/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ResourcesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/resources/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ResourcesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/resources: + * get: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Get all resources + * description: Get all resources + * responses: + * 200: + * description: Resources list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await ResourcesDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'title', 'content']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/resources/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Count all resources + * description: Count all resources + * responses: + * 200: + * description: Resources count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await ResourcesDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/resources/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Find all resources that match search criteria + * description: Find all resources that match search criteria + * responses: + * 200: + * description: Resources list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Resources" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await ResourcesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/resources/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Resources] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Resources" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ResourcesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js new file mode 100644 index 0000000..5f9cbf9 --- /dev/null +++ b/backend/src/routes/roles.js @@ -0,0 +1,433 @@ +const express = require('express'); + +const RolesService = require('../services/roles'); +const RolesDBApi = require('../db/api/roles'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('roles')); + +/** + * @swagger + * components: + * schemas: + * Roles: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Roles + * description: The Roles managing API + */ + +/** + * @swagger + * /api/roles: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Roles" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await RolesService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await RolesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Roles" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await RolesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await RolesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await RolesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get all roles + * description: Get all roles + * responses: + * 200: + * description: Roles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await RolesDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/roles/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Count all roles + * description: Count all roles + * responses: + * 200: + * description: Roles count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await RolesDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Find all roles that match search criteria + * description: Find all roles that match search criteria + * responses: + * 200: + * description: Roles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await RolesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/roles/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await RolesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js new file mode 100644 index 0000000..b848adb --- /dev/null +++ b/backend/src/routes/search.js @@ -0,0 +1,54 @@ +const express = require('express'); +const SearchService = require('../services/search'); + +const router = express.Router(); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); +router.use(checkCrudPermissions('search')); + +/** + * @swagger + * path: + * /api/search: + * post: + * summary: Search + * description: Search results across multiple tables + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * searchQuery: + * type: string + * required: + * - searchQuery + * responses: + * 200: + * description: Successful request + * 400: + * description: Invalid request + * 500: + * description: Internal server error + */ + +router.post('/', async (req, res) => { + const { searchQuery } = req.body; + + if (!searchQuery) { + return res.status(400).json({ error: 'Please enter a search query' }); + } + + try { + const foundMatches = await SearchService.search( + searchQuery, + req.currentUser, + ); + res.json(foundMatches); + } catch (error) { + console.error('Internal Server Error', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/sessions.js b/backend/src/routes/sessions.js new file mode 100644 index 0000000..a13ec3f --- /dev/null +++ b/backend/src/routes/sessions.js @@ -0,0 +1,438 @@ +const express = require('express'); + +const SessionsService = require('../services/sessions'); +const SessionsDBApi = require('../db/api/sessions'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('sessions')); + +/** + * @swagger + * components: + * schemas: + * Sessions: + * type: object + * properties: + + * notes: + * type: string + * default: notes + + */ + +/** + * @swagger + * tags: + * name: Sessions + * description: The Sessions managing API + */ + +/** + * @swagger + * /api/sessions: + * post: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Sessions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SessionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Sessions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SessionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/sessions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Sessions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await SessionsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/sessions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await SessionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/sessions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await SessionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/sessions: + * get: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Get all sessions + * description: Get all sessions + * responses: + * 200: + * description: Sessions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await SessionsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'notes', 'start_time', 'end_time']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/sessions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Count all sessions + * description: Count all sessions + * responses: + * 200: + * description: Sessions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await SessionsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/sessions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Find all sessions that match search criteria + * description: Find all sessions that match search criteria + * responses: + * 200: + * description: Sessions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Sessions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await SessionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/sessions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Sessions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Sessions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await SessionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/supervisors.js b/backend/src/routes/supervisors.js new file mode 100644 index 0000000..fd0b96e --- /dev/null +++ b/backend/src/routes/supervisors.js @@ -0,0 +1,438 @@ +const express = require('express'); + +const SupervisorsService = require('../services/supervisors'); +const SupervisorsDBApi = require('../db/api/supervisors'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('supervisors')); + +/** + * @swagger + * components: + * schemas: + * Supervisors: + * type: object + * properties: + + */ + +/** + * @swagger + * tags: + * name: Supervisors + * description: The Supervisors managing API + */ + +/** + * @swagger + * /api/supervisors: + * post: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Supervisors" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SupervisorsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Supervisors" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SupervisorsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/supervisors/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Supervisors" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await SupervisorsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/supervisors/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await SupervisorsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/supervisors/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await SupervisorsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/supervisors: + * get: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Get all supervisors + * description: Get all supervisors + * responses: + * 200: + * description: Supervisors list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await SupervisorsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/supervisors/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Count all supervisors + * description: Count all supervisors + * responses: + * 200: + * description: Supervisors count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await SupervisorsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/supervisors/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Find all supervisors that match search criteria + * description: Find all supervisors that match search criteria + * responses: + * 200: + * description: Supervisors list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Supervisors" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await SupervisorsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/supervisors/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Supervisors] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Supervisors" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await SupervisorsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/therapists.js b/backend/src/routes/therapists.js new file mode 100644 index 0000000..1fbe7cd --- /dev/null +++ b/backend/src/routes/therapists.js @@ -0,0 +1,438 @@ +const express = require('express'); + +const TherapistsService = require('../services/therapists'); +const TherapistsDBApi = require('../db/api/therapists'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('therapists')); + +/** + * @swagger + * components: + * schemas: + * Therapists: + * type: object + * properties: + + * specialization: + * type: string + * default: specialization + + */ + +/** + * @swagger + * tags: + * name: Therapists + * description: The Therapists managing API + */ + +/** + * @swagger + * /api/therapists: + * post: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Therapists" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TherapistsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Therapists" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TherapistsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/therapists/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Therapists" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await TherapistsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/therapists/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await TherapistsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/therapists/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await TherapistsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/therapists: + * get: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Get all therapists + * description: Get all therapists + * responses: + * 200: + * description: Therapists list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await TherapistsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'specialization']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/therapists/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Count all therapists + * description: Count all therapists + * responses: + * 200: + * description: Therapists count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await TherapistsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/therapists/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Find all therapists that match search criteria + * description: Find all therapists that match search criteria + * responses: + * 200: + * description: Therapists list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Therapists" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await TherapistsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/therapists/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Therapists] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Therapists" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await TherapistsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..a6d214f --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,444 @@ +const express = require('express'); + +const UsersService = require('../services/users'); +const UsersDBApi = require('../db/api/users'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('users')); + +/** + * @swagger + * components: + * schemas: + * Users: + * type: object + * properties: + + * firstName: + * type: string + * default: firstName + * lastName: + * type: string + * default: lastName + * phoneNumber: + * type: string + * default: phoneNumber + * email: + * type: string + * default: email + + */ + +/** + * @swagger + * tags: + * name: Users + * description: The Users managing API + */ + +/** + * @swagger + * /api/users: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Users" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await UsersService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Users" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await UsersService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Users" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await UsersService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await UsersService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await UsersService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Get all users + * description: Get all users + * responses: + * 200: + * description: Users list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await UsersDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'firstName', 'lastName', 'phoneNumber', 'email']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/users/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Count all users + * description: Count all users + * responses: + * 200: + * description: Users count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await UsersDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Find all users that match search criteria + * description: Find all users that match search criteria + * responses: + * 200: + * description: Users list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await UsersDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/users/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await UsersDBApi.findBy({ id: req.params.id }); + + delete payload.password; + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/administrators.js b/backend/src/services/administrators.js new file mode 100644 index 0000000..1477921 --- /dev/null +++ b/backend/src/services/administrators.js @@ -0,0 +1,117 @@ +const db = require('../db/models'); +const AdministratorsDBApi = require('../db/api/administrators'); +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'); + +module.exports = class AdministratorsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await AdministratorsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await AdministratorsDBApi.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 { + let administrators = await AdministratorsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!administrators) { + throw new ValidationError('administratorsNotFound'); + } + + const updatedAdministrators = await AdministratorsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedAdministrators; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await AdministratorsDBApi.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 AdministratorsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js new file mode 100644 index 0000000..2a5ccc2 --- /dev/null +++ b/backend/src/services/auth.js @@ -0,0 +1,226 @@ +const UsersDBApi = require('../db/api/users'); +const ValidationError = require('./notifications/errors/validation'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const bcrypt = require('bcrypt'); +const EmailAddressVerificationEmail = require('./email/list/addressVerification'); +const InvitationEmail = require('./email/list/invitation'); +const PasswordResetEmail = require('./email/list/passwordReset'); +const EmailSender = require('./email'); +const config = require('../config'); +const helpers = require('../helpers'); + +class Auth { + static async signup(email, password, options = {}, host) { + const user = await UsersDBApi.findBy({ email }); + + const hashedPassword = await bcrypt.hash( + password, + config.bcrypt.saltRounds, + ); + + if (user) { + if (user.authenticationUid) { + throw new ValidationError('auth.emailAlreadyInUse'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + await UsersDBApi.updatePassword(user.id, hashedPassword, options); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(user.email, host); + } + + const data = { + user: { + id: user.id, + email: user.email, + }, + }; + + return helpers.jwtSign(data); + } + + const newUser = await UsersDBApi.createFromAuth( + { + firstName: email.split('@')[0], + password: hashedPassword, + email: email, + }, + options, + ); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(newUser.email, host); + } + + const data = { + user: { + id: newUser.id, + email: newUser.email, + }, + }; + + return helpers.jwtSign(data); + } + + static async signin(email, password, options = {}) { + const user = await UsersDBApi.findBy({ email }); + + if (!user) { + throw new ValidationError('auth.userNotFound'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user.password) { + throw new ValidationError('auth.wrongPassword'); + } + + if (!EmailSender.isConfigured) { + user.emailVerified = true; + } + + if (!user.emailVerified) { + throw new ValidationError('auth.userNotVerified'); + } + + const passwordsMatch = await bcrypt.compare(password, user.password); + + if (!passwordsMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const data = { + user: { + id: user.id, + email: user.email, + }, + }; + + return helpers.jwtSign(data); + } + + static async sendEmailAddressVerificationEmail(email, host) { + let link; + try { + const token = await UsersDBApi.generateEmailVerificationToken(email); + link = `${host}/verify-email?token=${token}`; + } catch (error) { + console.error(error); + throw new ValidationError('auth.emailAddressVerificationEmail.error'); + } + + const emailAddressVerificationEmail = new EmailAddressVerificationEmail( + email, + link, + ); + + return new EmailSender(emailAddressVerificationEmail).send(); + } + + static async sendPasswordResetEmail(email, type = 'register', host) { + let link; + + try { + const token = await UsersDBApi.generatePasswordResetToken(email); + link = `${host}/password-reset?token=${token}`; + } catch (error) { + console.error(error); + throw new ValidationError('auth.passwordReset.error'); + } + + let passwordResetEmail; + if (type === 'register') { + passwordResetEmail = new PasswordResetEmail(email, link); + } + if (type === 'invitation') { + passwordResetEmail = new InvitationEmail(email, link); + } + + return new EmailSender(passwordResetEmail).send(); + } + + static async verifyEmail(token, options = {}) { + const user = await UsersDBApi.findByEmailVerificationToken(token, options); + + if (!user) { + throw new ValidationError( + 'auth.emailAddressVerificationEmail.invalidToken', + ); + } + + return UsersDBApi.markEmailVerified(user.id, options); + } + + static async passwordUpdate(currentPassword, newPassword, options) { + const currentUser = options.currentUser || null; + if (!currentUser) { + throw new ForbiddenError(); + } + + const currentPasswordMatch = await bcrypt.compare( + currentPassword, + currentUser.password, + ); + + if (!currentPasswordMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const newPasswordMatch = await bcrypt.compare( + newPassword, + currentUser.password, + ); + + if (newPasswordMatch) { + throw new ValidationError('auth.passwordUpdate.samePassword'); + } + + const hashedPassword = await bcrypt.hash( + newPassword, + config.bcrypt.saltRounds, + ); + + return UsersDBApi.updatePassword(currentUser.id, hashedPassword, options); + } + + static async passwordReset(token, password, options = {}) { + const user = await UsersDBApi.findByPasswordResetToken(token, options); + + if (!user) { + throw new ValidationError('auth.passwordReset.invalidToken'); + } + + const hashedPassword = await bcrypt.hash( + password, + config.bcrypt.saltRounds, + ); + + return UsersDBApi.updatePassword(user.id, hashedPassword, options); + } + + static async updateProfile(data, currentUser) { + let transaction = await db.sequelize.transaction(); + + try { + await UsersDBApi.findBy({ id: currentUser.id }, { transaction }); + + await UsersDBApi.update(currentUser.id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +module.exports = Auth; diff --git a/backend/src/services/clients.js b/backend/src/services/clients.js new file mode 100644 index 0000000..3b7e84d --- /dev/null +++ b/backend/src/services/clients.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const ClientsDBApi = require('../db/api/clients'); +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'); + +module.exports = class ClientsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ClientsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await ClientsDBApi.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 { + let clients = await ClientsDBApi.findBy({ id }, { transaction }); + + if (!clients) { + throw new ValidationError('clientsNotFound'); + } + + const updatedClients = await ClientsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedClients; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ClientsDBApi.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 ClientsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html b/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html new file mode 100644 index 0000000..95d8b3f --- /dev/null +++ b/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html @@ -0,0 +1,52 @@ + + + + + + + + + diff --git a/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html new file mode 100644 index 0000000..e685483 --- /dev/null +++ b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html @@ -0,0 +1,56 @@ + + + + + + + + + diff --git a/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html b/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html new file mode 100644 index 0000000..c77f215 --- /dev/null +++ b/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/backend/src/services/email/index.js b/backend/src/services/email/index.js new file mode 100644 index 0000000..fa7f3c7 --- /dev/null +++ b/backend/src/services/email/index.js @@ -0,0 +1,41 @@ +const config = require('../../config'); +const assert = require('assert'); +const nodemailer = require('nodemailer'); + +module.exports = class EmailSender { + constructor(email) { + this.email = email; + } + + async send() { + assert(this.email, 'email is required'); + assert(this.email.to, 'email.to is required'); + assert(this.email.subject, 'email.subject is required'); + assert(this.email.html, 'email.html is required'); + + const htmlContent = await this.email.html(); + + const transporter = nodemailer.createTransport(this.transportConfig); + + const mailOptions = { + from: this.from, + to: this.email.to, + subject: this.email.subject, + html: htmlContent, + }; + + return transporter.sendMail(mailOptions); + } + + static get isConfigured() { + return !!config.email?.auth?.pass && !!config.email?.auth?.user; + } + + get transportConfig() { + return config.email; + } + + get from() { + return config.email.from; + } +}; diff --git a/backend/src/services/email/list/addressVerification.js b/backend/src/services/email/list/addressVerification.js new file mode 100644 index 0000000..89be6d3 --- /dev/null +++ b/backend/src/services/email/list/addressVerification.js @@ -0,0 +1,41 @@ +const { getNotification } = require('../../notifications/helpers'); +const fs = require('fs').promises; +const path = require('path'); + +module.exports = class EmailAddressVerificationEmail { + constructor(to, link) { + this.to = to; + this.link = link; + } + + get subject() { + return getNotification( + 'emails.emailAddressVerification.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/addressVerification/emailAddressVerification.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = this.link; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/email/list/invitation.js b/backend/src/services/email/list/invitation.js new file mode 100644 index 0000000..d2afc1e --- /dev/null +++ b/backend/src/services/email/list/invitation.js @@ -0,0 +1,41 @@ +const fs = require('fs').promises; +const path = require('path'); +const { getNotification } = require('../../notifications/helpers'); + +module.exports = class InvitationEmail { + constructor(to, host) { + this.to = to; + this.host = host; + } + + get subject() { + return getNotification( + 'emails.invitation.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/invitation/invitationTemplate.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = `${this.host}&invitation=true`; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/email/list/passwordReset.js b/backend/src/services/email/list/passwordReset.js new file mode 100644 index 0000000..68ba353 --- /dev/null +++ b/backend/src/services/email/list/passwordReset.js @@ -0,0 +1,42 @@ +const { getNotification } = require('../../notifications/helpers'); +const path = require('path'); +const { promises: fs } = require('fs'); + +module.exports = class PasswordResetEmail { + constructor(to, link) { + this.to = to; + this.link = link; + } + + get subject() { + return getNotification( + 'emails.passwordReset.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/passwordReset/passwordResetEmail.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const resetUrl = this.link; + const accountName = this.to; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{resetUrl}/g, resetUrl) + .replace(/{accountName}/g, accountName); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/file.js b/backend/src/services/file.js new file mode 100644 index 0000000..cb08164 --- /dev/null +++ b/backend/src/services/file.js @@ -0,0 +1,202 @@ +const formidable = require('formidable'); +const fs = require('fs'); +const config = require('../config'); +const path = require('path'); +const { format } = require('util'); + +const ensureDirectoryExistence = (filePath) => { + const dirname = path.dirname(filePath); + + if (fs.existsSync(dirname)) { + return true; + } + + ensureDirectoryExistence(dirname); + fs.mkdirSync(dirname); +}; + +const uploadLocal = ( + folder, + validations = { + entity: null, + maxFileSize: null, + folderIncludesAuthenticationUid: false, + }, +) => { + return (req, res) => { + if (!req.currentUser) { + res.sendStatus(403); + return; + } + + if (validations.entity) { + res.sendStatus(403); + return; + } + + if (validations.folderIncludesAuthenticationUid) { + folder = folder.replace(':userId', req.currentUser.authenticationUid); + if ( + !req.currentUser.authenticationUid || + !folder.includes(req.currentUser.authenticationUid) + ) { + res.sendStatus(403); + return; + } + } + + const form = new formidable.IncomingForm(); + form.uploadDir = config.uploadDir; + + if (validations && validations.maxFileSize) { + form.maxFileSize = validations.maxFileSize; + } + + form.parse(req, function (err, fields, files) { + const filename = String(fields.filename); + const fileTempUrl = files.file.path; + + if (!filename) { + fs.unlinkSync(fileTempUrl); + res.sendStatus(500); + return; + } + + const privateUrl = path.join(form.uploadDir, folder, filename); + ensureDirectoryExistence(privateUrl); + fs.renameSync(fileTempUrl, privateUrl); + res.sendStatus(200); + }); + + form.on('error', function (err) { + res.status(500).send(err); + }); + }; +}; + +const downloadLocal = async (req, res) => { + const privateUrl = req.query.privateUrl; + if (!privateUrl) { + return res.sendStatus(404); + } + res.download(path.join(config.uploadDir, privateUrl)); +}; + +const initGCloud = () => { + const processFile = require('../middlewares/upload'); + const { Storage } = require('@google-cloud/storage'); + + const crypto = require('crypto'); + const hash = config.gcloud.hash; + + const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n'); + + const storage = new Storage({ + projectId: process.env.GC_PROJECT_ID, + credentials: { + client_email: process.env.GC_CLIENT_EMAIL, + private_key: privateKey, + }, + }); + + const bucket = storage.bucket(config.gcloud.bucket); + return { hash, bucket, processFile }; +}; + +const uploadGCloud = async (folder, req, res) => { + try { + const { hash, bucket, processFile } = initGCloud(); + await processFile(req, res); + let buffer = await req.file.buffer; + let filename = await req.body.filename; + + if (!req.file) { + return res.status(400).send({ message: 'Please upload a file!' }); + } + + let path = `${hash}/${folder}/${filename}`; + let blob = bucket.file(path); + + console.log(path); + + const blobStream = blob.createWriteStream({ + resumable: false, + }); + + blobStream.on('error', (err) => { + console.log('Upload error'); + console.log(err.message); + res.status(500).send({ message: err.message }); + }); + + console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + + blobStream.on('finish', async (data) => { + const publicUrl = format( + `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + ); + + res.status(200).send({ + message: 'Uploaded the file successfully: ' + path, + url: publicUrl, + }); + }); + + blobStream.end(buffer); + } catch (err) { + console.log(err); + + res.status(500).send({ + message: `Could not upload the file. ${err}`, + }); + } +}; + +const downloadGCloud = async (req, res) => { + try { + const { hash, bucket, processFile } = initGCloud(); + + const privateUrl = await req.query.privateUrl; + const filePath = `${hash}/${privateUrl}`; + const file = bucket.file(filePath); + const fileExists = await file.exists(); + + if (fileExists[0]) { + const stream = file.createReadStream(); + stream.pipe(res); + } else { + res.status(404).send({ + message: 'Could not download the file. ' + err, + }); + } + } catch (err) { + res.status(404).send({ + message: 'Could not download the file. ' + err, + }); + } +}; + +const deleteGCloud = async (privateUrl) => { + try { + const { hash, bucket, processFile } = initGCloud(); + const filePath = `${hash}/${privateUrl}`; + + const file = bucket.file(filePath); + const fileExists = await file.exists(); + + if (fileExists[0]) { + file.delete(); + } + } catch (err) { + console.log(`Cannot find the file ${privateUrl}`); + } +}; + +module.exports = { + initGCloud, + uploadLocal, + downloadLocal, + deleteGCloud, + uploadGCloud, + downloadGCloud, +}; diff --git a/backend/src/services/notifications/errors/forbidden.js b/backend/src/services/notifications/errors/forbidden.js new file mode 100644 index 0000000..192fa10 --- /dev/null +++ b/backend/src/services/notifications/errors/forbidden.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ForbiddenError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.forbidden.message'); + + super(message); + this.code = 403; + } +}; diff --git a/backend/src/services/notifications/errors/validation.js b/backend/src/services/notifications/errors/validation.js new file mode 100644 index 0000000..464550c --- /dev/null +++ b/backend/src/services/notifications/errors/validation.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ValidationError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.validation.message'); + + super(message); + this.code = 400; + } +}; diff --git a/backend/src/services/notifications/helpers.js b/backend/src/services/notifications/helpers.js new file mode 100644 index 0000000..1c3a60f --- /dev/null +++ b/backend/src/services/notifications/helpers.js @@ -0,0 +1,30 @@ +const _get = require('lodash/get'); +const errors = require('./list'); + +function format(message, args) { + if (!message) { + return null; + } + + return message.replace(/{(\d+)}/g, function (match, number) { + return typeof args[number] != 'undefined' ? args[number] : match; + }); +} + +const isNotification = (key) => { + const message = _get(errors, key); + return !!message; +}; + +const getNotification = (key, ...args) => { + const message = _get(errors, key); + + if (!message) { + return key; + } + + return format(message, args); +}; + +exports.getNotification = getNotification; +exports.isNotification = isNotification; diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js new file mode 100644 index 0000000..0d92566 --- /dev/null +++ b/backend/src/services/notifications/list.js @@ -0,0 +1,100 @@ +const errors = { + app: { + title: 'mental health wellbing app', + }, + + auth: { + userDisabled: 'Your account is disabled', + forbidden: 'Forbidden', + unauthorized: 'Unauthorized', + userNotFound: `Sorry, we don't recognize your credentials`, + wrongPassword: `Sorry, we don't recognize your credentials`, + weakPassword: 'This password is too weak', + emailAlreadyInUse: 'Email is already in use', + invalidEmail: 'Please provide a valid email', + passwordReset: { + invalidToken: 'Password reset link is invalid or has expired', + error: `Email not recognized`, + }, + passwordUpdate: { + samePassword: `You can't use the same password. Please create new password`, + }, + userNotVerified: `Sorry, your email has not been verified yet`, + emailAddressVerificationEmail: { + invalidToken: 'Email verification link is invalid or has expired', + error: `Email not recognized`, + }, + }, + + iam: { + errors: { + userAlreadyExists: 'User with this email already exists', + userNotFound: 'User not found', + disablingHimself: `You can't disable yourself`, + revokingOwnPermission: `You can't revoke your own owner permission`, + deletingHimself: `You can't delete yourself`, + emailRequired: 'Email is required', + }, + }, + + importer: { + errors: { + invalidFileEmpty: 'The file is empty', + invalidFileExcel: 'Only excel (.xlsx) files are allowed', + invalidFileUpload: + 'Invalid file. Make sure you are using the last version of the template.', + importHashRequired: 'Import hash is required', + importHashExistent: 'Data has already been imported', + userEmailMissing: 'Some items in the CSV do not have an email', + }, + }, + + errors: { + forbidden: { + message: 'Forbidden', + }, + validation: { + message: 'An error occurred', + }, + searchQueryRequired: { + message: 'Search query is required', + }, + }, + + emails: { + invitation: { + subject: `You've been invited to {0}`, + body: ` +

Hello,

+

You've been invited to {0} set password for your {1} account.

+

{2}

+

Thanks,

+

Your {0} team

+ `, + }, + emailAddressVerification: { + subject: `Verify your email for {0}`, + body: ` +

Hello,

+

Follow this link to verify your email address.

+

{0}

+

If you didn't ask to verify this address, you can ignore this email.

+

Thanks,

+

Your {1} team

+ `, + }, + passwordReset: { + subject: `Reset your password for {0}`, + body: ` +

Hello,

+

Follow this link to reset your {0} password for your {1} account.

+

{2}

+

If you didn't ask to reset your password, you can ignore this email.

+

Thanks,

+

Your {0} team

+ `, + }, + }, +}; + +module.exports = errors; diff --git a/backend/src/services/openai.js b/backend/src/services/openai.js new file mode 100644 index 0000000..947a3d1 --- /dev/null +++ b/backend/src/services/openai.js @@ -0,0 +1,22 @@ +const axios = require('axios'); +const { v4: uuid } = require('uuid'); +const RoleService = require('./roles'); +const config = require('../config'); + +module.exports = class OpenAiService { + static async getWidget(payload, userId, roleId) { + const response = await axios.post( + `${config.flHost}/${config.project_uuid}/project_customization_widgets.json`, + payload, + ); + + if (response.status >= 200 && response.status < 300) { + const { widget_id } = await response.data; + await RoleService.addRoleInfo(roleId, userId, 'widgets', widget_id); + return widget_id; + } else { + console.error('=======error=======', response.data); + return { value: null, error: response.data }; + } + } +}; diff --git a/backend/src/services/permissions.js b/backend/src/services/permissions.js new file mode 100644 index 0000000..ad78c26 --- /dev/null +++ b/backend/src/services/permissions.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const PermissionsDBApi = require('../db/api/permissions'); +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'); + +module.exports = class PermissionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await PermissionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await PermissionsDBApi.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 { + let permissions = await PermissionsDBApi.findBy({ id }, { transaction }); + + if (!permissions) { + throw new ValidationError('permissionsNotFound'); + } + + const updatedPermissions = await PermissionsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedPermissions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PermissionsDBApi.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 PermissionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/resources.js b/backend/src/services/resources.js new file mode 100644 index 0000000..7022f88 --- /dev/null +++ b/backend/src/services/resources.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const ResourcesDBApi = require('../db/api/resources'); +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'); + +module.exports = class ResourcesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ResourcesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await ResourcesDBApi.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 { + let resources = await ResourcesDBApi.findBy({ id }, { transaction }); + + if (!resources) { + throw new ValidationError('resourcesNotFound'); + } + + const updatedResources = await ResourcesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedResources; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ResourcesDBApi.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 ResourcesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js new file mode 100644 index 0000000..05b6fd2 --- /dev/null +++ b/backend/src/services/roles.js @@ -0,0 +1,430 @@ +const db = require('../db/models'); +const RolesDBApi = require('../db/api/roles'); +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'); + +function buildWidgetResult(widget, queryResult, queryString) { + if (queryResult[0] && queryResult[0].length) { + const key = Object.keys(queryResult[0][0])[0]; + const value = + widget.widget_type === 'scalar' ? queryResult[0][0][key] : queryResult[0]; + const widgetData = JSON.parse(widget.data); + return { ...widget, ...widgetData, value, query: queryString }; + } else { + return { ...widget, value: [], query: queryString }; + } +} + +async function executeQuery(queryString, currentUser) { + try { + return await db.sequelize.query(queryString, { + replacements: { organizationId: currentUser.organizationId }, + }); + } catch (e) { + console.log(e); + return []; + } +} + +function insertWhereConditions(queryString, whereConditions) { + if (!whereConditions) return queryString; + + const whereIndex = queryString.toLowerCase().indexOf('where'); + const groupByIndex = queryString.toLowerCase().indexOf('group by'); + const insertIndex = + whereIndex === -1 + ? groupByIndex !== -1 + ? groupByIndex + : queryString.length + : whereIndex + 5; + + const prefix = queryString.substring(0, insertIndex); + const suffix = queryString.substring(insertIndex); + const conditionString = + whereIndex === -1 + ? ` WHERE ${whereConditions} ` + : ` ${whereConditions} AND `; + + return `${prefix}${conditionString}${suffix}`; +} + +function constructWhereConditions(mainTable, currentUser, replacements) { + const { + organizationId, + app_role: { globalAccess }, + } = currentUser; + const tablesWithoutOrgId = ['permissions', 'roles']; + let whereConditions = ''; + + if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) { + whereConditions += `"${mainTable}"."organizationId" = :organizationId`; + replacements.organizationId = organizationId; + } + + if (mainTable !== 'users') { + whereConditions += whereConditions ? ' AND ' : ''; + whereConditions += `"${mainTable}"."deletedAt" IS NULL`; + } + + return whereConditions; +} + +function extractTableName(queryString) { + const tableNameRegex = /FROM\s+("?)([^"\s]+)\1\s*/i; + const match = tableNameRegex.exec(queryString); + return match ? match[2] : null; +} + +function buildQueryString(widget, currentUser) { + let queryString = widget?.query || ''; + const tableName = extractTableName(queryString); + const mainTable = JSON.parse(widget?.data)?.main_table || tableName; + const replacements = {}; + const whereConditions = constructWhereConditions( + mainTable, + currentUser, + replacements, + ); + queryString = insertWhereConditions(queryString, whereConditions); + console.log(queryString, 'queryString'); + return queryString; +} + +async function constructWidgetsResults(widgets, currentUser) { + const widgetsResults = []; + for (const widget of widgets) { + if (!widget) continue; + const queryString = buildQueryString(widget, currentUser); + const queryResult = await executeQuery(queryString, currentUser); + widgetsResults.push(buildWidgetResult(widget, queryResult, queryString)); + } + return widgetsResults; +} + +async function fetchWidgetsData(widgets) { + const widgetPromises = (widgets || []).map((widgetId) => + axios.get( + `${config.flHost}/${config.project_uuid}/project_customization_widgets/${widgetId}.json`, + ), + ); + const widgetResults = widgetPromises + ? await Promise.allSettled(widgetPromises) + : []; + return widgetResults + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value.data); +} + +async function processWidgets(widgets, currentUser) { + const widgetData = await fetchWidgetsData(widgets); + return constructWidgetsResults(widgetData, currentUser); +} + +function parseCustomization(role) { + try { + return JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + return {}; + } +} + +async function findRole(roleId, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const role = roleId + ? await RolesDBApi.findBy({ id: roleId }, { transaction }) + : await RolesDBApi.findBy({ name: 'User' }, { transaction }); + await transaction.commit(); + return role; + } catch (error) { + await transaction.rollback(); + throw error; + } +} + +module.exports = class RolesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await RolesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await RolesDBApi.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 { + let roles = await RolesDBApi.findBy({ id }, { transaction }); + + if (!roles) { + throw new ValidationError('rolesNotFound'); + } + + const updatedRoles = await RolesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedRoles; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await RolesDBApi.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 RolesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async addRoleInfo(roleId, userId, key, widgetId, currentUser) { + const regexExpForUuid = + /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi; + const widgetIdIsUUID = regexExpForUuid.test(widgetId); + + const transaction = await db.sequelize.transaction(); + let role; + if (roleId) { + role = await RolesDBApi.findBy({ id: roleId }, { transaction }); + } else { + role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); + } + + if (!role) { + throw new ValidationError('rolesNotFound'); + } + + try { + let customization = {}; + try { + customization = JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + } + + if (widgetIdIsUUID && Array.isArray(customization[key])) { + const el = customization[key].find((e) => e === widgetId); + !el ? customization[key].unshift(widgetId) : null; + } + + if (widgetIdIsUUID && !customization[key]) { + customization[key] = [widgetId]; + } + + const newRole = await RolesDBApi.update( + role.id, + { + role_customization: JSON.stringify(customization), + name: role.name, + permissions: role.permissions, + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + + return newRole; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async removeRoleInfoById(infoId, roleId, key, currentUser) { + const transaction = await db.sequelize.transaction(); + + let role; + if (roleId) { + role = await RolesDBApi.findBy({ id: roleId }, { transaction }); + } else { + role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); + } + if (!role) { + await transaction.rollback(); + throw new ValidationError('rolesNotFound'); + } + + let customization = {}; + try { + customization = JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + } + + customization[key] = customization[key].filter((item) => item !== infoId); + + const response = await axios.delete( + `${config.flHost}/${config.project_uuid}/project_customization_widgets/${infoId}.json`, + ); + const { status } = await response; + try { + const result = await RolesDBApi.update( + role.id, + { + role_customization: JSON.stringify(customization), + name: role.name, + permissions: role.permissions, + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async getRoleInfoByKey(key, roleId, currentUser) { + const transaction = await db.sequelize.transaction(); + + const organizationId = currentUser.organizationId; + let globalAccess = currentUser.app_role?.globalAccess; + let queryString = ''; + + let role; + try { + if (roleId) { + role = await RolesDBApi.findBy({ id: roleId }, { transaction }); + } else { + role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + let customization = '{}'; + + try { + customization = JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + } + + if (key === 'widgets') { + const widgets = customization[key] || []; + const widgetArray = widgets.map((widget) => { + return axios.get( + `${config.flHost}/${config.project_uuid}/project_customization_widgets/${widget}.json`, + ); + }); + const widgetResults = await Promise.allSettled(widgetArray); + + const fulfilledWidgets = widgetResults.map((result) => { + if (result.status === 'fulfilled') { + return result.value.data; + } + }); + + const widgetsResults = []; + + if (Array.isArray(fulfilledWidgets)) { + for (const widget of fulfilledWidgets) { + let result = []; + try { + result = await db.sequelize.query(widget.query); + } catch (e) { + console.log(e); + } + + if (result[0] && result[0].length) { + const key = Object.keys(result[0][0])[0]; + const value = + widget.widget_type === 'scalar' ? result[0][0][key] : result[0]; + const widgetData = JSON.parse(widget.data); + widgetsResults.push({ ...widget, ...widgetData, value }); + } else { + widgetsResults.push({ ...widget, value: null }); + } + } + } + return widgetsResults; + } + return customization[key]; + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js new file mode 100644 index 0000000..1c49239 --- /dev/null +++ b/backend/src/services/search.js @@ -0,0 +1,139 @@ +const db = require('../db/models'); +const ValidationError = require('./notifications/errors/validation'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +/** + * @param {string} permission + * @param {object} currentUser + */ +async function checkPermissions(permission, currentUser) { + if (!currentUser) { + throw new ValidationError('auth.unauthorized'); + } + + const userPermission = currentUser.custom_permissions.find( + (cp) => cp.name === permission, + ); + + if (userPermission) { + return true; + } + + try { + if (!currentUser.app_role) { + throw new ValidationError('auth.forbidden'); + } + + const permissions = await currentUser.app_role.getPermissions(); + + return !!permissions.find((p) => p.name === permission); + } catch (e) { + throw e; + } +} + +module.exports = class SearchService { + static async search(searchQuery, currentUser) { + try { + if (!searchQuery) { + throw new ValidationError('iam.errors.searchQueryRequired'); + } + const tableColumns = { + users: ['firstName', 'lastName', 'phoneNumber', 'email'], + + administrators: ['role'], + + clients: ['mood'], + + resources: ['title', 'content'], + + sessions: ['notes'], + + therapists: ['specialization'], + }; + const columnsInt = {}; + + let allFoundRecords = []; + + for (const tableName in tableColumns) { + if (tableColumns.hasOwnProperty(tableName)) { + const attributesToSearch = tableColumns[tableName]; + const attributesIntToSearch = columnsInt[tableName] || []; + const whereCondition = { + [Op.or]: [ + ...attributesToSearch.map((attribute) => ({ + [attribute]: { + [Op.iLike]: `%${searchQuery}%`, + }, + })), + ...attributesIntToSearch.map((attribute) => + Sequelize.where( + Sequelize.cast( + Sequelize.col(`${tableName}.${attribute}`), + 'varchar', + ), + { [Op.iLike]: `%${searchQuery}%` }, + ), + ), + ], + }; + + const hasPermission = await checkPermissions( + `READ_${tableName.toUpperCase()}`, + currentUser, + ); + if (!hasPermission) { + continue; + } + + const foundRecords = await db[tableName].findAll({ + where: whereCondition, + attributes: [ + ...tableColumns[tableName], + 'id', + ...attributesIntToSearch, + ], + }); + + const modifiedRecords = foundRecords.map((record) => { + const matchAttribute = []; + + for (const attribute of attributesToSearch) { + if ( + record[attribute] + ?.toLowerCase() + ?.includes(searchQuery.toLowerCase()) + ) { + matchAttribute.push(attribute); + } + } + + for (const attribute of attributesIntToSearch) { + const castedValue = String(record[attribute]); + if ( + castedValue && + castedValue.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + matchAttribute.push(attribute); + } + } + + return { + ...record.get(), + matchAttribute, + tableName, + }; + }); + + allFoundRecords = allFoundRecords.concat(modifiedRecords); + } + } + + return allFoundRecords; + } catch (error) { + throw error; + } + } +}; diff --git a/backend/src/services/sessions.js b/backend/src/services/sessions.js new file mode 100644 index 0000000..8251599 --- /dev/null +++ b/backend/src/services/sessions.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const SessionsDBApi = require('../db/api/sessions'); +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'); + +module.exports = class SessionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await SessionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await SessionsDBApi.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 { + let sessions = await SessionsDBApi.findBy({ id }, { transaction }); + + if (!sessions) { + throw new ValidationError('sessionsNotFound'); + } + + const updatedSessions = await SessionsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedSessions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SessionsDBApi.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 SessionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/supervisors.js b/backend/src/services/supervisors.js new file mode 100644 index 0000000..76e508f --- /dev/null +++ b/backend/src/services/supervisors.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const SupervisorsDBApi = require('../db/api/supervisors'); +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'); + +module.exports = class SupervisorsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await SupervisorsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await SupervisorsDBApi.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 { + let supervisors = await SupervisorsDBApi.findBy({ id }, { transaction }); + + if (!supervisors) { + throw new ValidationError('supervisorsNotFound'); + } + + const updatedSupervisors = await SupervisorsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedSupervisors; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SupervisorsDBApi.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 SupervisorsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/therapists.js b/backend/src/services/therapists.js new file mode 100644 index 0000000..7984267 --- /dev/null +++ b/backend/src/services/therapists.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const TherapistsDBApi = require('../db/api/therapists'); +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'); + +module.exports = class TherapistsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await TherapistsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + 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)); + }); + + await TherapistsDBApi.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 { + let therapists = await TherapistsDBApi.findBy({ id }, { transaction }); + + if (!therapists) { + throw new ValidationError('therapistsNotFound'); + } + + const updatedTherapists = await TherapistsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedTherapists; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TherapistsDBApi.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 TherapistsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/users.js b/backend/src/services/users.js new file mode 100644 index 0000000..b6de7a8 --- /dev/null +++ b/backend/src/services/users.js @@ -0,0 +1,152 @@ +const db = require('../db/models'); +const UsersDBApi = require('../db/api/users'); +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 InvitationEmail = require('./email/list/invitation'); +const EmailSender = require('./email'); +const AuthService = require('./auth'); + +module.exports = class UsersService { + static async create(data, currentUser, sendInvitationEmails = true, host) { + let transaction = await db.sequelize.transaction(); + + let email = data.email; + let emailsToInvite = []; + try { + if (email) { + let user = await UsersDBApi.findBy({ email }, { transaction }); + if (user) { + throw new ValidationError('iam.errors.userAlreadyExists'); + } else { + await UsersDBApi.create( + { data }, + + { + currentUser, + transaction, + }, + ); + emailsToInvite.push(email); + } + } else { + throw new ValidationError('iam.errors.emailRequired'); + } + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + if (emailsToInvite && emailsToInvite.length) { + if (!sendInvitationEmails) return; + + AuthService.sendPasswordResetEmail(email, 'invitation', host); + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + let emailsToInvite = []; + + 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', () => { + console.log('results csv', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + const hasAllEmails = results.every((result) => result.email); + + if (!hasAllEmails) { + throw new ValidationError('importer.errors.userEmailMissing'); + } + + await UsersDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + emailsToInvite = results.map((result) => result.email); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) { + emailsToInvite.forEach((email) => { + AuthService.sendPasswordResetEmail(email, 'invitation', host); + }); + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + let users = await UsersDBApi.findBy({ id }, { transaction }); + + if (!users) { + throw new ValidationError('iam.errors.userNotFound'); + } + + const updatedUser = await UsersDBApi.update( + id, + data, + + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedUser; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + if (currentUser.id === id) { + throw new ValidationError('iam.errors.deletingHimself'); + } + + if (currentUser.app_role?.name !== config.roles.admin) { + throw new ValidationError('errors.forbidden.message'); + } + + await UsersDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/yarn.lock b/backend/yarn.lock new file mode 100644 index 0000000..222a4f9 --- /dev/null +++ b/backend/yarn.lock @@ -0,0 +1,4470 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@apidevtools/json-schema-ref-parser@^9.0.6": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz#8ff5386b365d4c9faa7c8b566ff16a46a577d9b8" + integrity sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.6" + call-me-maybe "^1.0.1" + js-yaml "^4.1.0" + +"@apidevtools/openapi-schemas@^2.0.4": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" + integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== + +"@apidevtools/swagger-methods@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" + integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== + +"@apidevtools/swagger-parser@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz#32057ae99487872c4dd96b314a1ab4b95d89eaf5" + integrity sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g== + dependencies: + "@apidevtools/json-schema-ref-parser" "^9.0.6" + "@apidevtools/openapi-schemas" "^2.0.4" + "@apidevtools/swagger-methods" "^3.0.2" + "@jsdevtools/ono" "^7.1.3" + call-me-maybe "^1.0.1" + z-schema "^5.0.1" + +"@azure/abort-controller@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" + integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== + dependencies: + tslib "^2.2.0" + +"@azure/abort-controller@^2.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz#42fe0ccab23841d9905812c58f1082d27784566d" + integrity sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA== + dependencies: + tslib "^2.6.2" + +"@azure/core-auth@^1.3.0", "@azure/core-auth@^1.4.0", "@azure/core-auth@^1.5.0": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.7.2.tgz#558b7cb7dd12b00beec07ae5df5907d74df1ebd9" + integrity sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-util" "^1.1.0" + tslib "^2.6.2" + +"@azure/core-client@^1.3.0", "@azure/core-client@^1.5.0", "@azure/core-client@^1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@azure/core-client/-/core-client-1.9.2.tgz#6fc69cee2816883ab6c5cdd653ee4f2ff9774f74" + integrity sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-rest-pipeline" "^1.9.1" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.6.1" + "@azure/logger" "^1.0.0" + tslib "^2.6.2" + +"@azure/core-http-compat@^2.0.1": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz#d1585ada24ba750dc161d816169b33b35f762f0d" + integrity sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-client" "^1.3.0" + "@azure/core-rest-pipeline" "^1.3.0" + +"@azure/core-lro@^2.2.0": + version "2.7.2" + resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-2.7.2.tgz#787105027a20e45c77651a98b01a4d3b01b75a08" + integrity sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-util" "^1.2.0" + "@azure/logger" "^1.0.0" + tslib "^2.6.2" + +"@azure/core-paging@^1.1.1": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.6.2.tgz#40d3860dc2df7f291d66350b2cfd9171526433e7" + integrity sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA== + dependencies: + tslib "^2.6.2" + +"@azure/core-rest-pipeline@^1.1.0", "@azure/core-rest-pipeline@^1.3.0", "@azure/core-rest-pipeline@^1.8.1", "@azure/core-rest-pipeline@^1.9.1": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.2.tgz#3f71b09e45a65926cc598478b4f1bcd0fe67bf4b" + integrity sha512-Hnhm/PG9/SQ07JJyLDv3l9Qr8V3xgAe1hFoBYzt6LaalMxfL/ZqFaZf/bz5VN3pMcleCPwl8ivlS2Fjxq/iC8Q== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.9.0" + "@azure/logger" "^1.0.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.0" + tslib "^2.6.2" + +"@azure/core-tracing@^1.0.0", "@azure/core-tracing@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.1.2.tgz#065dab4e093fb61899988a1cdbc827d9ad90b4ee" + integrity sha512-dawW9ifvWAWmUm9/h+/UQ2jrdvjCJ7VJEuCJ6XVNudzcOwm53BFZH4Q845vjfgoUAM8ZxokvVNxNxAITc502YA== + dependencies: + tslib "^2.6.2" + +"@azure/core-util@^1.0.0", "@azure/core-util@^1.1.0", "@azure/core-util@^1.2.0", "@azure/core-util@^1.3.0", "@azure/core-util@^1.6.1", "@azure/core-util@^1.9.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.9.1.tgz#05ea9505c5cdf29c55ccf99a648c66ddd678590b" + integrity sha512-OLsq0etbHO1MA7j6FouXFghuHrAFGk+5C1imcpQ2e+0oZhYF07WLA+NW2Vqs70R7d+zOAWiWM3tbE1sXcDN66g== + dependencies: + "@azure/abort-controller" "^2.0.0" + tslib "^2.6.2" + +"@azure/identity@^4.2.1": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.4.0.tgz#f2743e63d346000a70b0eed5a3b397dedd3984a7" + integrity sha512-oG6oFNMxUuoivYg/ElyZWVSZfw42JQyHbrp+lR7VJ1BYWsGzt34NwyDw3miPp1QI7Qm5+4KAd76wGsbHQmkpkg== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.5.0" + "@azure/core-client" "^1.9.2" + "@azure/core-rest-pipeline" "^1.1.0" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.3.0" + "@azure/logger" "^1.0.0" + "@azure/msal-browser" "^3.14.0" + "@azure/msal-node" "^2.9.2" + events "^3.0.0" + jws "^4.0.0" + open "^8.0.0" + stoppable "^1.1.0" + tslib "^2.2.0" + +"@azure/keyvault-keys@^4.4.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@azure/keyvault-keys/-/keyvault-keys-4.8.0.tgz#1513b3a187bb3a9a372b5980c593962fb793b2ad" + integrity sha512-jkuYxgkw0aaRfk40OQhFqDIupqblIOIlYESWB6DKCVDxQet1pyv86Tfk9M+5uFM0+mCs6+MUHU+Hxh3joiUn4Q== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.3.0" + "@azure/core-client" "^1.5.0" + "@azure/core-http-compat" "^2.0.1" + "@azure/core-lro" "^2.2.0" + "@azure/core-paging" "^1.1.1" + "@azure/core-rest-pipeline" "^1.8.1" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.0.0" + "@azure/logger" "^1.0.0" + tslib "^2.2.0" + +"@azure/logger@^1.0.0": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.1.3.tgz#09a8fd4850b9112865756e92d5e8b728ee457345" + integrity sha512-J8/cIKNQB1Fc9fuYqBVnrppiUtW+5WWJPCj/tAokC5LdSTwkWWttN+jsRgw9BLYD7JDBx7PceiqOBxJJ1tQz3Q== + dependencies: + tslib "^2.6.2" + +"@azure/msal-browser@^3.14.0": + version "3.19.1" + resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.19.1.tgz#c5e5a7996f95cadc11920bffa2bf6321e3a24555" + integrity sha512-pqYP2gK0GCEa4OxtOqlS+EdFQqhXV6ZuESgSTYWq2ABXyxBVVdd5KNuqgR5SU0OwI2V1YWdFVvLDe1487dyQ0g== + dependencies: + "@azure/msal-common" "14.13.1" + +"@azure/msal-common@14.13.1": + version "14.13.1" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.13.1.tgz#e296cf8cc556082af9c35d803496424e8a95d8b7" + integrity sha512-iUp3BYrsRZ4X3EiaZ2fDjNFjmtYMv9rEQd6c1op6ULn0HWk4ACvDmosL6NaBgWOhl1BAblIbd9vmB5/ilF8d4A== + +"@azure/msal-node@^2.9.2": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.11.1.tgz#7fea67a1c6904301eb8853fae7df86c34306a9cc" + integrity sha512-8ECtug4RL+zsgh20VL8KYHjrRO3MJOeAKEPRXT2lwtiu5U3BdyIdBb50+QZthEkIi60K6pc/pdOx/k5Jp4sLng== + dependencies: + "@azure/msal-common" "14.13.1" + jsonwebtoken "^9.0.0" + uuid "^8.3.0" + +"@google-cloud/paginator@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" + integrity sha512-jJNutk0arIQhmpUUQJPJErsojqo834KcyB6X7a1mxuic8i1tKXxde8E69IZxNZawRIlZdIK2QY4WALvlK5MzYQ== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-2.1.1.tgz#ae6af4fee02d78d044ae434699a630f8df0084ef" + integrity sha512-+rssMZHnlh0twl122gXY4/aCrk0G1acBqkHFfYddtsqpYXGxA29nj9V5V9SfC+GyOG00l650f6lG9KL+EpFEWQ== + +"@google-cloud/promisify@^2.0.0": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-2.0.4.tgz#9d8705ecb2baa41b6b2673f3a8e9b7b7e1abc52a" + integrity sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA== + +"@google-cloud/storage@^5.18.2": + version "5.20.5" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-5.20.5.tgz#1de71fc88d37934a886bc815722c134b162d335d" + integrity sha512-lOs/dCyveVF8TkVFnFSF7IGd0CJrTm91qiK6JLu+Z8qiT+7Ag0RyVhxZIWkhiACqwABo7kSHDm8FdH8p2wxSSw== + dependencies: + "@google-cloud/paginator" "^3.0.7" + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^2.0.0" + abort-controller "^3.0.0" + arrify "^2.0.0" + async-retry "^1.3.3" + compressible "^2.0.12" + configstore "^5.0.0" + duplexify "^4.0.0" + ent "^2.2.0" + extend "^3.0.2" + gaxios "^4.0.0" + google-auth-library "^7.14.1" + hash-stream-validation "^0.2.2" + mime "^3.0.0" + mime-types "^2.0.8" + p-limit "^3.0.1" + pumpify "^2.0.0" + retry-request "^4.2.2" + stream-events "^1.0.4" + teeny-request "^7.1.3" + uuid "^8.0.0" + xdg-basedir "^4.0.0" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@js-joda/core@^5.6.1": + version "5.6.3" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" + integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== + +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@sindresorhus/is@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" + integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== + +"@szmarczak/http-timer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" + integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== + dependencies: + defer-to-connect "^1.0.1" + +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + +"@types/debug@^4.1.8": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/json-schema@^7.0.6": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + +"@types/node@*", "@types/node@>=18": + version "20.14.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.11.tgz#09b300423343460455043ddd4d0ded6ac579b74b" + integrity sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA== + dependencies: + undici-types "~5.26.4" + +"@types/readable-stream@^4.0.0": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-4.0.15.tgz#e6ec26fe5b02f578c60baf1fa9452e90957d2bfb" + integrity sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw== + dependencies: + "@types/node" "*" + safe-buffer "~5.1.1" + +"@types/validator@^13.7.17": + version "13.12.0" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.0.tgz#1fe4c3ae9de5cf5193ce64717c99ef2fa7d8756f" + integrity sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@^1.3.7, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + +ansi-align@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" + integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== + dependencies: + string-width "^4.1.0" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +anymatch@~3.1.1, anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array.prototype.map@^1.0.1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.7.tgz#82fa4d6027272d1fca28a63bbda424d0185d78a7" + integrity sha512-XpcFfLoBEAhezrrNw1V+yLXkE7M6uR7xJEsxbG6c/V9v043qurwVJB9r9UTnoSioFDoz1i1VOydpWGmJpfVZbg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-array-method-boxes-properly "^1.0.0" + es-object-atoms "^1.0.0" + is-string "^1.0.7" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +arrify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + +async-retry@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" + integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== + dependencies: + retry "0.13.1" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +axios@^1.6.7: + version "1.7.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" + integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.0, base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +base64url@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + +bcrypt@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + node-addon-api "^5.0.0" + +bignumber.js@^9.0.0: + version "9.1.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" + integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bl@^6.0.11: + version "6.0.14" + resolved "https://registry.yarnpkg.com/bl/-/bl-6.0.14.tgz#b9ae9862118a3d2ebec999c5318466012314f96c" + integrity sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ== + dependencies: + "@types/readable-stream" "^4.0.0" + buffer "^6.0.3" + inherits "^2.0.4" + readable-stream "^4.2.0" + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + +boxen@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" + integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^5.3.1" + chalk "^3.0.0" + cli-boxes "^2.2.0" + string-width "^4.1.0" + term-size "^2.1.0" + type-fest "^0.8.1" + widest-line "^3.1.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg== + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cacheable-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" + integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^3.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^1.0.2" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + +chokidar@^3.2.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cli-boxes@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + +cli-color@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.4.tgz#d658080290968816b322248b7306fad2346fb2c8" + integrity sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA== + dependencies: + d "^1.0.1" + es5-ext "^0.10.64" + es6-iterator "^2.0.3" + memoizee "^0.4.15" + timers-ext "^0.1.7" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-response@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" + integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== + dependencies: + mimic-response "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +compressible@^2.0.12: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +configstore@^5.0.0, configstore@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" + integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== + dependencies: + dot-prop "^5.2.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + unique-string "^2.0.0" + write-file-atomic "^3.0.0" + xdg-basedir "^4.0.0" + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-env@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + +csv-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + +d@1, d@^1.0.1, d@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.2.tgz#2aefd554b81981e7dccf72d6842ae725cb17e5de" + integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== + dependencies: + es5-ext "^0.10.64" + type "^2.7.2" + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.1, debug@^4.3.4: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + +debug@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + integrity sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA== + dependencies: + mimic-response "^1.0.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +defer-to-connect@^1.0.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" + integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + +define-properties@^1.1.2, define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +denque@^1.4.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg== + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + +diff@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +doctrine@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dot-prop@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + dependencies: + is-obj "^2.0.0" + +dottie@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/dottie/-/dottie-2.0.6.tgz#34564ebfc6ec5e5772272d466424ad5b696484d4" + integrity sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA== + +duplexer3@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e" + integrity sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA== + +duplexify@^4.0.0, duplexify@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" + integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== + dependencies: + end-of-stream "^1.4.1" + inherits "^2.0.3" + readable-stream "^3.1.1" + stream-shift "^1.0.2" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +editorconfig@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +ent@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.1.tgz#68dc99a002f115792c26239baedaaea9e70c0ca2" + integrity sha512-QHuXVeZx9d+tIQAz/XztU0ZwZf2Agg9CcXcgE1rurqvdBeDBrpSwjl8/6XUqMg7tw2Y7uAdKb2sRv+bSEFqQ5A== + dependencies: + punycode "^1.4.1" + +es-abstract@^1.17.0-next.1, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-get-iterator@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== + dependencies: + es6-iterator "^2.0.3" + es6-symbol "^3.1.3" + esniff "^2.0.1" + next-tick "^1.1.0" + +es6-iterator@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-symbol@^3.1.1, es6-symbol@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.4.tgz#f4e7d28013770b4208ecbf3e0bf14d3bcb557b8c" + integrity sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg== + dependencies: + d "^1.0.2" + ext "^1.7.0" + +es6-weak-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + +escalade@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== + dependencies: + d "1" + es5-ext "~0.10.14" + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.0.0, events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +express@4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.1" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +ext@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" + integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== + dependencies: + type "^2.7.2" + +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-text-encoding@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" + integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flat@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.1.tgz#a392059cc382881ff98642f5da4dde0a959f309b" + integrity sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA== + dependencies: + is-buffer "~2.0.3" + +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +foreground-child@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" + integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formidable@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" + integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2, fresh@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +gaxios@^4.0.0: + version "4.3.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.3.3.tgz#d44bdefe52d34b6435cc41214fdb160b64abfc22" + integrity sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.7" + +gcp-metadata@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.3.1.tgz#fb205fe6a90fef2fd9c85e6ba06e5559ee1eefa9" + integrity sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A== + dependencies: + gaxios "^4.0.0" + json-bigint "^1.0.0" + +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +glob-parent@~5.1.0, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^10.3.3: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-dirs@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" + integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== + dependencies: + ini "1.3.7" + +globalthis@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +google-auth-library@^7.14.1: + version "7.14.1" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" + integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-p12-pem@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" + integrity sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg== + dependencies: + node-forge "^1.3.1" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +got@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" + integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== + dependencies: + "@sindresorhus/is" "^0.14.0" + "@szmarczak/http-timer" "^1.1.2" + cacheable-request "^6.0.0" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^4.1.0" + lowercase-keys "^1.0.1" + mimic-response "^1.0.1" + p-cancelable "^1.0.0" + to-readable-stream "^1.0.0" + url-parse-lax "^3.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +gtoken@^5.0.4: + version "5.3.2" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.3.2.tgz#deb7dc876abe002178e0515e383382ea9446d58f" + integrity sha512-gkvEKREW7dXWF8NV8pVrKfW7WqReAmjjkMBh6lNCCGOM4ucS0r0YyXXl0r/9Yj8wcW/32ISkfc8h5mPTDbtifQ== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.1.3" + jws "^4.0.0" + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.0, has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +has-yarn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" + integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== + +hash-stream-validation@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz#ee68b41bf822f7f44db1142ec28ba9ee7ccb7512" + integrity sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ== + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +helmet@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.1.1.tgz#751f0e273d809ace9c172073e0003bed27d27a4a" + integrity sha512-Avg4XxSBrehD94mkRwEljnO+6RZx7AGfk8Wa6K1nxaU+hbXlFOhlOIMgPfFqOYQB/dBCsTpootTGuiOG+CHiQA== + +http-cache-semantics@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +https-proxy-agent@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +import-lazy@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" + integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflection@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.13.4.tgz#65aa696c4e2da6225b148d7a154c449366633a32" + integrity sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== + +ini@^1.3.4, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +internal-slot@^1.0.4, internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-arguments@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-buffer@~2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-core-module@^2.13.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea" + integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-installed-globally@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-map@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-npm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" + integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + +is-path-inside@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== + +is-promise@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-set@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +is-yarn-global@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" + integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +iterate-iterator@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz#551b804c9eaa15b847ea6a7cdc2f5bf1ec150f91" + integrity sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw== + +iterate-value@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" + integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== + dependencies: + es-get-iterator "^1.0.2" + iterate-iterator "^1.0.1" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.14.5: + version "1.15.1" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64" + integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.3.3" + js-cookie "^3.0.5" + nopt "^7.2.0" + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +js-md4@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" + integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== + +js-yaml@3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + integrity sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ== + +json2csv@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae" + integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA== + dependencies: + commander "^6.1.0" + jsonparse "^1.3.1" + lodash.get "^4.4.2" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +jsonwebtoken@8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + +jsonwebtoken@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + +keyv@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" + integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== + dependencies: + json-buffer "3.0.0" + +latest-version@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" + integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== + dependencies: + package-json "^6.3.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.mergewith@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@4.17.21, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + +lru-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== + dependencies: + es5-ext "~0.10.2" + +make-dir@^3.0.0, make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memoizee@^0.4.15: + version "0.4.17" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.17.tgz#942a5f8acee281fa6fb9c620bddc57e3b7382949" + integrity sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA== + dependencies: + d "^1.0.2" + es5-ext "^0.10.64" + es6-weak-map "^2.0.3" + event-emitter "^0.3.5" + is-promise "^2.2.2" + lru-queue "^0.1.0" + next-tick "^1.1.0" + timers-ext "^0.1.7" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +merge-descriptors@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +methods@^1.1.2, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +"mime-db@>= 1.43.0 < 2": + version "1.53.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== + +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0, mime@^1.3.4: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + +mimic-response@^1.0.0, mimic-response@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +minimatch@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mocha@8.1.3: + version "8.1.3" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.1.3.tgz#5e93f873e35dfdd69617ea75f9c68c2ca61c2ac5" + integrity sha512-ZbaYib4hT4PpF4bdSO2DohooKXIn4lDeiYqB+vTmCdr6l2woW0b6H3pf5x4sM5nwQMru9RvjjHYWVGltR50ZBw== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.4.2" + debug "4.1.1" + diff "4.0.2" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "3.14.0" + log-symbols "4.0.0" + minimatch "3.0.4" + ms "2.1.2" + object.assign "4.1.0" + promise.allsettled "1.0.2" + serialize-javascript "4.0.0" + strip-json-comments "3.0.1" + supports-color "7.1.0" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.0.0" + yargs "13.3.2" + yargs-parser "13.1.2" + yargs-unparser "1.6.1" + +moment-timezone@^0.5.43: + version "0.5.45" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" + integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== + dependencies: + moment "^2.29.4" + +moment@2.30.1, moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multer@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" + integrity sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + +mysql2@2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-2.2.5.tgz#72624ffb4816f80f96b9c97fedd8c00935f9f340" + integrity sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g== + dependencies: + denque "^1.4.1" + generate-function "^2.3.1" + iconv-lite "^0.6.2" + long "^4.0.0" + lru-cache "^6.0.0" + named-placeholders "^1.1.2" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + +named-placeholders@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351" + integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w== + dependencies: + lru-cache "^7.14.1" + +native-duplexpair@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/native-duplexpair/-/native-duplexpair-1.0.0.tgz#7899078e64bf3c8a3d732601b3d40ff05db58fa0" + integrity sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +next-tick@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-fetch@^2.6.1, node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + +node-mocks-http@1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.9.0.tgz#6000c570fc4b809603782309be81c73a71d85b71" + integrity sha512-ILf7Ws8xyX9Rl2fLZ7xhZBovrRwgaP84M13esndP6V17M/8j25TpwNzb7Im8U9XCo6fRhdwqiQajWXpsas/E6w== + dependencies: + accepts "^1.3.7" + depd "^1.1.0" + fresh "^0.5.2" + merge-descriptors "^1.0.1" + methods "^1.1.2" + mime "^1.3.4" + parseurl "^1.3.3" + range-parser "^1.2.0" + type-is "^1.6.18" + +nodemailer@6.9.9: + version "6.9.9" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.9.tgz#4549bfbf710cc6addec5064dd0f19874d24248d9" + integrity sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA== + +nodemon@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.5.tgz#df67fe1fd1312ddb0c1e393ae2cf55aacdcec2f3" + integrity sha512-6/jqtZvJdk092pVnD2AIH19KQ9GQZAKOZVy/yT1ueL6aoV+Ix7a1lVZStXzvEh0fP4zE41DDWlkVoHjR6WlozA== + dependencies: + chokidar "^3.2.2" + debug "^3.2.6" + ignore-by-default "^1.0.1" + minimatch "^3.0.4" + pstree.remy "^1.1.7" + semver "^5.7.1" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.3" + update-notifier "^4.1.0" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +nopt@^7.2.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-url@^4.1.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" + integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +oauth@0.10.x: + version "0.10.0" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.10.0.tgz#3551c4c9b95c53ea437e1e21e46b649482339c58" + integrity sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q== + +oauth@0.9.x: + version "0.9.15" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" + integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA== + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +object-keys@^1.0.11, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +on-finished@2.4.1, on-finished@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +open@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + +p-cancelable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" + integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== + +p-limit@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.1, p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + +package-json@^6.3.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" + integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== + dependencies: + got "^9.6.0" + registry-auth-token "^4.0.0" + registry-url "^5.0.0" + semver "^6.2.0" + +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + +parseurl@^1.3.3, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +passport-google-oauth2@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz#fc9ea59e7091f02e24fd16d6be9257ea982ebbc3" + integrity sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ== + dependencies: + passport-oauth2 "^1.1.2" + +passport-jwt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" + integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== + dependencies: + jsonwebtoken "^9.0.0" + passport-strategy "^1.0.0" + +passport-microsoft@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/passport-microsoft/-/passport-microsoft-0.1.0.tgz#dc72c1a38b294d74f4dc55fe93f52e25cb9aa5b4" + integrity sha512-0giBDgE1fnR5X84zJZkQ11hnKVrzEgViwRO6RGsormK9zTxFQmN/UHMTDbIpvhk989VqALewB6Pk1R5vNr3GHw== + dependencies: + passport-oauth2 "1.2.0" + pkginfo "0.2.x" + +passport-oauth2@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.2.0.tgz#49613a3eca85c7a1e65bf1019e2b6b80a10c8ac2" + integrity sha512-6128N+n/MOrJdXxdC2q/PVKXtqgihGFIeup+9bsPybAvMPOUKqdGhh9ZIzZF8rFKJOlxUP9fgP3H0JQe18n0rg== + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + +passport-oauth2@^1.1.2: + version "1.8.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" + integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== + dependencies: + base64url "3.x.x" + oauth "0.10.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + +passport-strategy@1.x.x, passport-strategy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== + +passport@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" + integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + utils-merge "^1.0.1" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== + +pg-connection-string@^2.4.0, pg-connection-string@^2.6.1: + version "2.6.4" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.4.tgz#f543862adfa49fa4e14bc8a8892d2a84d754246d" + integrity sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA== + +pg-hstore@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/pg-hstore/-/pg-hstore-2.3.4.tgz#4425e3e2a3e15d2a334c35581186c27cf2e9b8dd" + integrity sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA== + dependencies: + underscore "^1.13.1" + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.2.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.2.tgz#3a592370b8ae3f02a7c8130d245bc02fa2c5f3f2" + integrity sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg== + +pg-protocol@^1.3.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" + integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@8.4.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.4.1.tgz#06cfb6208ae787a869b2f0022da11b90d13d933e" + integrity sha512-NRsH0aGMXmX1z8Dd0iaPCxWUw4ffu+lIAmGm+sTCwuDDWkpEgRCAHZYDwqaNhC5hG5DRMOjSUFasMWhvcmLN1A== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.4.0" + pg-pool "^3.2.1" + pg-protocol "^1.3.0" + pg-types "^2.1.0" + pgpass "1.x" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha512-7W7wTrE/NsY8xv/DTGjwNIyNah81EQH0MWcTzrHL6pOpMocOGZc0Mbdz9aXxSrp+U0mSmkU8jrNCDCfUs3sOBg== + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + integrity sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +promise.allsettled@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" + integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== + dependencies: + array.prototype.map "^1.0.1" + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + iterate-value "^1.0.0" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pstree.remy@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" + integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== + dependencies: + duplexify "^4.1.1" + inherits "^2.0.3" + pump "^3.0.0" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + +pupa@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" + integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== + dependencies: + escape-goat "^2.0.0" + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.0, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rc@1.2.8, rc@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^4.2.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +registry-auth-token@^4.0.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" + integrity sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg== + dependencies: + rc "1.2.8" + +registry-url@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" + integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== + dependencies: + rc "^1.2.8" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve@^1.22.1: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +responselike@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== + dependencies: + lowercase-keys "^1.0.0" + +retry-as-promised@^7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-7.0.4.tgz#9df73adaeea08cb2948b9d34990549dc13d800a2" + integrity sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA== + +retry-request@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.2.2.tgz#b7d82210b6d2651ed249ba3497f07ea602f1a903" + integrity sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg== + dependencies: + debug "^4.1.1" + extend "^3.0.2" + +retry@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver-diff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" + integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== + dependencies: + semver "^6.3.0" + +semver@^5.6.0, semver@^5.7.1: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== + +sequelize-cli@6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/sequelize-cli/-/sequelize-cli-6.6.2.tgz#8d838b25c988cf136914cdc3843e19d88c3dcb67" + integrity sha512-V8Oh+XMz2+uquLZltZES6MVAD+yEnmMfwfn+gpXcDiwE3jyQygLt4xoI0zG8gKt6cRcs84hsKnXAKDQjG/JAgg== + dependencies: + cli-color "^2.0.3" + fs-extra "^9.1.0" + js-beautify "^1.14.5" + lodash "^4.17.21" + resolve "^1.22.1" + umzug "^2.3.0" + yargs "^16.2.0" + +sequelize-json-schema@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz#a82d3813925e81485d76ce291f4ff5c8cb2ae492" + integrity sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q== + +sequelize-pool@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-7.1.0.tgz#210b391af4002762f823188fd6ecfc7413020768" + integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== + +sequelize@6.35.2: + version "6.35.2" + resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-6.35.2.tgz#9276d24055a9a07bd6812c89ab402659f5853e70" + integrity sha512-EdzLaw2kK4/aOnWQ7ed/qh3B6/g+1DvmeXr66RwbcqSm/+QRS9X0LDI5INBibsy4eNJHWIRPo3+QK0zL+IPBHg== + dependencies: + "@types/debug" "^4.1.8" + "@types/validator" "^13.7.17" + debug "^4.3.4" + dottie "^2.0.6" + inflection "^1.13.4" + lodash "^4.17.21" + moment "^2.29.4" + moment-timezone "^0.5.43" + pg-connection-string "^2.6.1" + retry-as-promised "^7.0.4" + semver "^7.5.4" + sequelize-pool "^7.1.0" + toposort-class "^1.0.1" + uuid "^8.3.2" + validator "^13.9.0" + wkx "^0.5.0" + +serialize-javascript@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +sqlite@4.0.15: + version "4.0.15" + resolved "https://registry.yarnpkg.com/sqlite/-/sqlite-4.0.15.tgz#071e0577afb327fbd74a75354ea15964378392e3" + integrity sha512-irPPTrbVoDvwzRGpe0v8vxpNwMl+q0tXQzffQTcCUnaJzQFO0hfLLvFwGDKxd6vYBuvEr3uvPkObVoGOvVsmzA== + +sqlstring@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" + integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +stop-iteration-iterator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" + integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== + dependencies: + internal-slot "^1.0.4" + +stoppable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b" + integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw== + +stream-events@^1.0.4, stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + +stream-shift@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" + integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== + +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== + +supports-color@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +swagger-jsdoc@^6.2.8: + version "6.2.8" + resolved "https://registry.yarnpkg.com/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz#6d33d9fb07ff4a7c1564379c52c08989ec7d0256" + integrity sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ== + dependencies: + commander "6.2.0" + doctrine "3.0.0" + glob "7.1.6" + lodash.mergewith "^4.6.2" + swagger-parser "^10.0.3" + yaml "2.0.0-1" + +swagger-parser@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/swagger-parser/-/swagger-parser-10.0.3.tgz#04cb01c18c3ac192b41161c77f81e79309135d03" + integrity sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg== + dependencies: + "@apidevtools/swagger-parser" "10.0.3" + +swagger-ui-dist@>=5.0.0: + version "5.17.14" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz#e2c222e5bf9e15ccf80ec4bc08b4aaac09792fd6" + integrity sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw== + +swagger-ui-express@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz#fb8c1b781d2793a6bd2f8a205a3f4bd6fa020dd8" + integrity sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA== + dependencies: + swagger-ui-dist ">=5.0.0" + +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +tedious@^18.2.4: + version "18.2.4" + resolved "https://registry.yarnpkg.com/tedious/-/tedious-18.2.4.tgz#c33986f2561b4fde92bb9df70f44ae1a14f71b46" + integrity sha512-+6Nzn/aURTQ+8OxLAJ8fKK5Fbb84HRTI3bHiAC3ZzBKrBg9BHtcHxjmlIni5Zn46hzKiZ5WrDMSwDH8oIYjV8w== + dependencies: + "@azure/identity" "^4.2.1" + "@azure/keyvault-keys" "^4.4.0" + "@js-joda/core" "^5.6.1" + "@types/node" ">=18" + bl "^6.0.11" + iconv-lite "^0.6.3" + js-md4 "^0.3.2" + native-duplexpair "^1.0.0" + sprintf-js "^1.1.3" + +teeny-request@^7.1.3: + version "7.2.0" + resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" + integrity sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw== + dependencies: + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + node-fetch "^2.6.1" + stream-events "^1.0.5" + uuid "^8.0.0" + +term-size@^2.1.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54" + integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg== + +timers-ext@^0.1.7: + version "0.1.8" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.8.tgz#b4e442f10b7624a29dd2aa42c295e257150cf16c" + integrity sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww== + dependencies: + es5-ext "^0.10.64" + next-tick "^1.1.0" + +to-readable-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" + integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +toposort-class@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" + integrity sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg== + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +tslib@^2.2.0, tslib@^2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type@^2.7.2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/type/-/type-2.7.3.tgz#436981652129285cc3ba94f392886c2637ea0486" + integrity sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +uid2@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" + integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== + +umzug@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.3.0.tgz#0ef42b62df54e216b05dcaf627830a6a8b84a184" + integrity sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw== + dependencies: + bluebird "^3.7.2" + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undefsafe@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +underscore@^1.13.1: + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-notifier@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" + integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + integrity sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ== + dependencies: + prepend-http "^2.0.0" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +validator@^13.7.0, validator@^13.9.0: + version "13.12.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f" + integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which-typed-array@^1.1.14, which-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@2.0.2, which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +wkx@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c" + integrity sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg== + dependencies: + "@types/node" "*" + +workerpool@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" + integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +xdg-basedir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" + integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@2.0.0-1: + version "2.0.0-1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-1.tgz#8c3029b3ee2028306d5bcf396980623115ff8d18" + integrity sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ== + +yargs-parser@13.1.2, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^15.0.1: + version "15.0.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.3.tgz#316e263d5febe8b38eef61ac092b33dfcc9b1115" + integrity sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.1.tgz#bd4b0ee05b4c94d058929c32cb09e3fce71d3c5f" + integrity sha512-qZV14lK9MWsGCmcr7u5oXGH0dbGqZAIxTDrWXZDo5zUr6b6iUmelNKO6x6R1dQT24AH3LgRxJpr8meWy2unolA== + dependencies: + camelcase "^5.3.1" + decamelize "^1.2.0" + flat "^4.1.0" + is-plain-obj "^1.1.0" + yargs "^14.2.3" + +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414" + integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg== + dependencies: + cliui "^5.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^15.0.1" + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +z-schema@^5.0.1: + version "5.0.6" + resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-5.0.6.tgz#46d6a687b15e4a4369e18d6cb1c7b8618fc256c5" + integrity sha512-+XR1GhnWklYdfr8YaZv/iu+vY+ux7V5DS5zH1DQf6bO5ufrt/5cgNhVO5qyhsjFXvsqQb/f08DWE9b6uPscyAg== + dependencies: + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + validator "^13.7.0" + optionalDependencies: + commander "^10.0.0" diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..f456727 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,11 @@ +module.exports = { + extends: [ + 'next/core-web-vitals', + 'eslint-config-prettier', + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + root: true, +}; diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..cedf9c7 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..56e10d0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20.15.1-alpine + +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN yarn install +# If you are building your code for production +# RUN npm ci --only=production + +# Bundle app source +COPY . . + +EXPOSE 3000 +CMD [ "yarn", "dev" ] \ No newline at end of file diff --git a/frontend/LICENSE-justboil b/frontend/LICENSE-justboil new file mode 100644 index 0000000..798238d --- /dev/null +++ b/frontend/LICENSE-justboil @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-current JustBoil.me (https://justboil.me) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a3f9f9f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,91 @@ +# mental health wellbing app + +## This project was generated by Flatlogic Platform. + +## Install + +`cd` to project's dir and run `npm install` + +### Builds + +Build are handled by Next.js CLI — [Info](https://nextjs.org/docs/api-reference/cli) + +### Hot-reloads for development + +``` +npm run dev +``` + +### Builds and minifies for production + +``` +npm run build +``` + +### Exports build for static hosts + +``` +npm run export +``` + +### Lint + +``` +npm run lint +``` + +### Format with prettier + +``` +npm run format +``` + +## Support + +For any additional information please refer to [Flatlogic homepage](https://flatlogic.com). + +## To start the project with Docker: + +### Description: + +The project contains the **docker folder** and the `Dockerfile`. + +The `Dockerfile` is used to Deploy the project to Google Cloud. + +The **docker folder** contains a couple of helper scripts: + +- `docker-compose.yml` (all our services: web, backend, db are described here) +- `start-backend.sh` (starts backend, but only after the database) +- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) + + > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. + +### Run services: + +1. Install docker compose (https://docs.docker.com/compose/install/) + +2. Move to `docker` folder. All next steps should be done from this folder. + + `cd docker` + +3. Make executables from `wait-for-it.sh` and `start-backend.sh`: + + `chmod +x start-backend.sh && chmod +x wait-for-it.sh` + +4. Download dependend projects for services. + +5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. + +6. Make sure you have needed ports (see them in `ports`) available on your local machine. + +7. Start services: + + 7.1. With an empty database `rm -rf data && docker-compose up` + + 7.2. With a stored (from previus runs) database data `docker-compose up` + +8. Check http://localhost:3000 + +9. Stop services: + + 9.1. Just press `Ctr+C` diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..a4a7b3f --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..19d1363 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,51 @@ +/** + * @type {import('next').NextConfig} + */ +const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone'; +const nextConfig = { + trailingSlash: true, + distDir: 'build', + output, + basePath: '', + swcMinify: false, + images: { + unoptimized: true, + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + }, + + async rewrites() { + return [ + { + source: '/home', + destination: '/web_pages/home', + }, + + { + source: '/contact', + destination: '/web_pages/contact', + }, + + { + source: '/about', + destination: '/web_pages/about', + }, + + { + source: '/services', + destination: '/web_pages/services', + }, + + { + source: '/faq', + destination: '/web_pages/faq', + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..84dd863 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,71 @@ +{ + "private": true, + "scripts": { + "dev": "cross-env PORT=${FRONT_PORT:-3000} next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mdi/js": "^7.4.47", + "@mui/material": "^6.3.0", + "@mui/x-data-grid": "^6.19.2", + "@reduxjs/toolkit": "^2.1.0", + "@tailwindcss/typography": "^0.5.13", + "@tinymce/tinymce-react": "^4.3.2", + "apexcharts": "^3.45.2", + "axios": "^1.6.7", + "chart.js": "^4.4.1", + "chroma-js": "^2.4.2", + "dayjs": "^1.11.10", + "file-saver": "^2.0.5", + "formik": "^2.4.5", + "intro.js": "^7.2.0", + "intro.js-react": "^1.0.0", + "jsonwebtoken": "^9.0.2", + "jwt-decode": "^3.1.2", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "next": "^14.1.0", + "numeral": "^2.0.6", + "query-string": "^8.1.0", + "react": "^19.0.0", + "react-apexcharts": "^1.4.1", + "react-big-calendar": "^1.10.3", + "react-chartjs-2": "^4.3.1", + "react-datepicker": "^4.10.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^19.0.0", + "react-toastify": "^11.0.2", + "react-redux": "^8.0.2", + "react-select": "^5.7.0", + "react-select-async-paginate": "^0.7.9", + "react-switch": "^7.0.0", + "swr": "^1.3.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/line-clamp": "^0.4.4", + "@types/node": "18.7.16", + "@types/numeral": "^2.0.2", + "@types/react-big-calendar": "^1.8.8", + "@types/react-redux": "^7.1.24", + "@typescript-eslint/eslint-plugin": "^5.37.0", + "@typescript-eslint/parser": "^5.37.0", + "autoprefixer": "^10.4.0", + "cross-env": "^7.0.3", + "eslint": "^8.23.1", + "eslint-config-next": "^13.0.4", + "eslint-config-prettier": "^8.5.0", + "postcss": "^8.4.4", + "postcss-import": "^14.1.0", + "prettier": "^3.2.4", + "tailwindcss": "^3.4.1", + "typescript": "^4.8.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..5bee7ce --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,9 @@ +/* eslint-env node */ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 0000000..d0ee549 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,13 @@ +module.exports = { + semi: false, + singleQuote: true, + printWidth: 100, + trailingComma: 'es5', + arrowParens: 'always', + tabWidth: 2, + useTabs: false, + quoteProps: 'as-needed', + jsxSingleQuote: false, + bracketSpacing: true, + bracketSameLine: false, +}; diff --git a/frontend/public/data-sources/clients.json b/frontend/public/data-sources/clients.json new file mode 100644 index 0000000..c97621a --- /dev/null +++ b/frontend/public/data-sources/clients.json @@ -0,0 +1,224 @@ +{ + "data": [ + { + "id": 19, + "avatar": "https://avatars.dicebear.com/v2/gridy/Howell-Hand.svg", + "login": "percy64", + "name": "Howell Hand", + "company": "Kiehn-Green", + "city": "Emelyside", + "progress": 70, + "created": "Mar 3, 2022", + "created_mm_dd_yyyy": "03-03-2022" + }, + { + "id": 11, + "avatar": "https://avatars.dicebear.com/v2/gridy/Hope-Howe.svg", + "login": "dare.concepcion", + "name": "Hope Howe", + "company": "Nolan Inc", + "city": "Paristown", + "progress": 68, + "created": "Dec 1, 2022", + "created_mm_dd_yyyy": "12-01-2022" + }, + { + "id": 32, + "avatar": "https://avatars.dicebear.com/v2/gridy/Nelson-Jerde.svg", + "login": "geovanni.kessler", + "name": "Nelson Jerde", + "company": "Nitzsche LLC", + "city": "Jailynbury", + "progress": 49, + "created": "May 18, 2022", + "created_mm_dd_yyyy": "05-18-2022" + }, + { + "id": 22, + "avatar": "https://avatars.dicebear.com/v2/gridy/Kim-Weimann.svg", + "login": "macejkovic.dashawn", + "name": "Kim Weimann", + "company": "Brown-Lueilwitz", + "city": "New Emie", + "progress": 38, + "created": "May 4, 2022", + "created_mm_dd_yyyy": "05-04-2022" + }, + { + "id": 34, + "avatar": "https://avatars.dicebear.com/v2/gridy/Justice-OReilly.svg", + "login": "hilpert.leora", + "name": "Justice O'Reilly", + "company": "Lakin-Muller", + "city": "New Kacie", + "progress": 38, + "created": "Mar 27, 2022", + "created_mm_dd_yyyy": "03-27-2022" + }, + { + "id": 48, + "avatar": "https://avatars.dicebear.com/v2/gridy/Adrienne-Mayer-III.svg", + "login": "ferry.sophia", + "name": "Adrienne Mayer III", + "company": "Kozey, McLaughlin and Kuhn", + "city": "Howardbury", + "progress": 39, + "created": "Mar 29, 2022", + "created_mm_dd_yyyy": "03-29-2022" + }, + { + "id": 20, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Julien-Ebert.svg", + "login": "gokuneva", + "name": "Mr. Julien Ebert", + "company": "Cormier LLC", + "city": "South Serenaburgh", + "progress": 29, + "created": "Jun 25, 2022", + "created_mm_dd_yyyy": "06-25-2022" + }, + { + "id": 47, + "avatar": "https://avatars.dicebear.com/v2/gridy/Lenna-Smitham.svg", + "login": "paolo.walter", + "name": "Lenna Smitham", + "company": "King Inc", + "city": "McCulloughfort", + "progress": 59, + "created": "Oct 8, 2022", + "created_mm_dd_yyyy": "10-08-2022" + }, + { + "id": 24, + "avatar": "https://avatars.dicebear.com/v2/gridy/Travis-Davis.svg", + "login": "lkessler", + "name": "Travis Davis", + "company": "Leannon and Sons", + "city": "West Frankton", + "progress": 52, + "created": "Oct 20, 2022", + "created_mm_dd_yyyy": "10-20-2022" + }, + { + "id": 49, + "avatar": "https://avatars.dicebear.com/v2/gridy/Prof.-Esteban-Steuber.svg", + "login": "shana.lang", + "name": "Prof. Esteban Steuber", + "company": "Langosh-Ernser", + "city": "East Sedrick", + "progress": 34, + "created": "May 16, 2022", + "created_mm_dd_yyyy": "05-16-2022" + }, + { + "id": 36, + "avatar": "https://avatars.dicebear.com/v2/gridy/Russell-Goodwin-V.svg", + "login": "jewel07", + "name": "Russell Goodwin V", + "company": "Nolan-Stracke", + "city": "Williamsonmouth", + "progress": 55, + "created": "Apr 22, 2022", + "created_mm_dd_yyyy": "04-22-2022" + }, + { + "id": 33, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Cassidy-Wiegand-DVM.svg", + "login": "burnice.okuneva", + "name": "Ms. Cassidy Wiegand DVM", + "company": "Kuhlman-Hahn", + "city": "New Ruthiehaven", + "progress": 76, + "created": "Sep 16, 2022", + "created_mm_dd_yyyy": "09-16-2022" + }, + { + "id": 44, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Watson-Brakus-PhD.svg", + "login": "oconnell.juanita", + "name": "Mr. Watson Brakus PhD", + "company": "Osinski, Bins and Kuhn", + "city": "Lake Gloria", + "progress": 58, + "created": "Jun 22, 2022", + "created_mm_dd_yyyy": "06-22-2022" + }, + { + "id": 46, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Garrison-Friesen-V.svg", + "login": "vgutmann", + "name": "Mr. Garrison Friesen V", + "company": "VonRueden, Rippin and Pfeffer", + "city": "Port Cieloport", + "progress": 39, + "created": "Oct 19, 2022", + "created_mm_dd_yyyy": "10-19-2022" + }, + { + "id": 14, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Sister-Morar.svg", + "login": "veum.lucio", + "name": "Ms. Sister Morar", + "company": "Gusikowski, Altenwerth and Abbott", + "city": "Lake Macville", + "progress": 34, + "created": "Jun 11, 2022", + "created_mm_dd_yyyy": "06-11-2022" + }, + { + "id": 40, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Laisha-Reinger.svg", + "login": "edietrich", + "name": "Ms. Laisha Reinger", + "company": "Boehm PLC", + "city": "West Alexiemouth", + "progress": 73, + "created": "Nov 2, 2022", + "created_mm_dd_yyyy": "11-02-2022" + }, + { + "id": 5, + "avatar": "https://avatars.dicebear.com/v2/gridy/Cameron-Lind.svg", + "login": "mose44", + "name": "Cameron Lind", + "company": "Tremblay, Padberg and Pouros", + "city": "Naderview", + "progress": 59, + "created": "Sep 14, 2022", + "created_mm_dd_yyyy": "09-14-2022" + }, + { + "id": 43, + "avatar": "https://avatars.dicebear.com/v2/gridy/Sarai-Little.svg", + "login": "rau.abelardo", + "name": "Sarai Little", + "company": "Deckow LLC", + "city": "Jeanieborough", + "progress": 49, + "created": "Jun 13, 2022", + "created_mm_dd_yyyy": "06-13-2022" + }, + { + "id": 2, + "avatar": "https://avatars.dicebear.com/v2/gridy/Shyann-Kautzer.svg", + "login": "imurazik", + "name": "Shyann Kautzer", + "company": "Osinski, Boehm and Kihn", + "city": "New Alvera", + "progress": 41, + "created": "Feb 15, 2022", + "created_mm_dd_yyyy": "02-15-2022" + }, + { + "id": 15, + "avatar": "https://avatars.dicebear.com/v2/gridy/Lorna-Christiansen.svg", + "login": "annalise97", + "name": "Lorna Christiansen", + "company": "Altenwerth-Friesen", + "city": "Port Elbertland", + "progress": 36, + "created": "Mar 9, 2022", + "created_mm_dd_yyyy": "03-09-2022" + } + ] +} diff --git a/frontend/public/data-sources/history.json b/frontend/public/data-sources/history.json new file mode 100644 index 0000000..12e81b6 --- /dev/null +++ b/frontend/public/data-sources/history.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "id": 1, + "amount": 375.53, + "account": "45721474", + "name": "Home Loan Account", + "date": "3 days ago", + "type": "deposit", + "business": "Turcotte" + }, + { + "id": 2, + "amount": 470.26, + "account": "94486537", + "name": "Savings Account", + "date": "3 days ago", + "type": "payment", + "business": "Murazik - Graham" + }, + { + "id": 3, + "amount": 971.34, + "account": "63189893", + "name": "Checking Account", + "date": "5 days ago", + "type": "invoice", + "business": "Fahey - Keebler" + }, + { + "id": 4, + "amount": 374.63, + "account": "74828780", + "name": "Auto Loan Account", + "date": "7 days ago", + "type": "withdraw", + "business": "Collier - Hintz" + } + ] +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..c8c4e3e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts new file mode 100644 index 0000000..bd3cd96 --- /dev/null +++ b/frontend/src/colors.ts @@ -0,0 +1,150 @@ +import type { ColorButtonKey } from './interfaces'; + +export const gradientBgBase = 'bg-gradient-to-tr'; +export const colorBgBase = 'bg-pastelEmeraldTheme-mainBG'; +export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`; +export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`; +export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`; +export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`; + +export const colorsBgLight = { + white: 'bg-white text-black', + light: + ' bg-pastelEmeraldTheme-outsideCardColor text-primaryText text-primaryText dark:bg-dark-900 dark:text-white', + contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black', + success: + 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', + danger: 'bg-red-500 border-red-500 text-white', + warning: 'bg-yellow-500 border-yellow-500 text-white', + info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', +}; + +export const colorsText = { + white: 'text-black dark:text-slate-100', + light: 'text-primaryText dark:text-slate-400', + contrast: 'dark:text-white', + success: 'text-emerald-500', + danger: 'text-red-500', + warning: 'text-yellow-500', + info: 'text-blue-500', +}; + +export const colorsOutline = { + white: [colorsText.white, 'border-gray-100'].join(' '), + light: [colorsText.light, 'border-gray-100'].join(' '), + contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join( + ' ', + ), + success: [colorsText.success, 'border-emerald-500'].join(' '), + danger: [colorsText.danger, 'border-red-500'].join(' '), + warning: [colorsText.warning, 'border-yellow-500'].join(' '), + info: [colorsText.info, 'border-blue-500'].join(' '), +}; + +export const getButtonColor = ( + color: ColorButtonKey, + isOutlined: boolean, + hasHover: boolean, + isActive = false, +) => { + if (color === 'void') { + return ''; + } + + const colors = { + ring: { + white: 'ring-gray-200 dark:ring-gray-500', + whiteDark: 'ring-pastelEmeraldTheme-outsideCardColor dark:ring-dark-500', + lightDark: 'ring-gray-200 dark:ring-gray-500', + contrast: 'ring-gray-300 dark:ring-gray-400', + success: 'ring-emerald-300 dark:ring-pavitra-blue', + danger: 'ring-red-300 dark:ring-red-700', + warning: 'ring-yellow-300 dark:ring-yellow-700', + info: 'ring-pastelEmeraldTheme-buttonColor dark:ring-pavitra-blue', + }, + active: { + white: 'bg-gray-100', + whiteDark: 'bg-gray-100 dark:bg-dark-800', + lightDark: 'bg-gray-200 dark:bg-slate-700', + contrast: 'bg-gray-700 dark:bg-slate-100', + success: 'bg-emerald-700 dark:bg-pavitra-blue', + danger: 'bg-red-700 dark:bg-red-600', + warning: 'bg-yellow-700 dark:bg-yellow-600', + info: 'bg-pastelEmeraldTheme-buttonColor dark:bg-pavitra-blue', + }, + bg: { + white: 'bg-white text-black', + whiteDark: + 'bg-pastelEmeraldTheme-outsideCardColor text-primaryText dark:bg-dark-900 dark:text-white', + lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white', + contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black', + success: 'bg-emerald-600 dark:bg-pavitra-blue text-white', + danger: + 'bg-pastelEmeraldTheme-outsideCardColor text-red-500 dark:text-white dark:bg-red-500 ', + warning: 'bg-yellow-600 dark:bg-yellow-500 text-white', + info: ' bg-pastelEmeraldTheme-buttonColor dark:bg-pavitra-blue text-white ', + }, + bgHover: { + white: 'hover:bg-gray-100', + whiteDark: + 'hover:bg-pastelEmeraldTheme-outsideCardColor hover:dark:bg-dark-800', + lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700', + contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100', + success: + 'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue', + danger: + 'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600', + warning: + 'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600', + info: 'hover:bg-pastelEmeraldTheme-800 hover:border-pastelEmeraldTheme-buttonColor hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80', + }, + borders: { + white: 'border-white', + whiteDark: + 'border-pastelEmeraldTheme-outsideCardColor dark:border-dark-900', + lightDark: 'border-gray-100 dark:border-slate-800', + contrast: 'border-gray-800 dark:border-white', + success: 'border-emerald-600 dark:border-pavitra-blue', + danger: 'border-red-600 dark:border-red-500', + warning: 'border-yellow-600 dark:border-yellow-500', + info: 'border-pastelEmeraldTheme-buttonColor border-blue-600 dark:border-pavitra-blue', + }, + text: { + contrast: 'dark:text-slate-100', + success: 'text-emerald-600 dark:text-pavitra-blue', + danger: 'text-red-600 dark:text-red-500', + warning: 'text-yellow-600 dark:text-yellow-500', + info: ' dark:text-pavitra-blue', + }, + outlineHover: { + contrast: + 'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black', + success: + 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue', + danger: + 'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600', + warning: + 'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600', + info: 'hover:bg-pastelEmeraldTheme-buttonColor text-pastelEmeraldTheme-buttonColor hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue', + }, + }; + + const isOutlinedProcessed = + isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0; + + const base = [colors.borders[color], colors.ring[color]]; + + if (isActive) { + base.push(colors.active[color]); + } else { + base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color]); + } + + if (hasHover) { + base.push( + isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color], + ); + } + + return base.join(' '); +}; diff --git a/frontend/src/components/Administrators/CardAdministrators.tsx b/frontend/src/components/Administrators/CardAdministrators.tsx new file mode 100644 index 0000000..72d571f --- /dev/null +++ b/frontend/src/components/Administrators/CardAdministrators.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + administrators: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardAdministrators = ({ + administrators, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_ADMINISTRATORS', + ); + + return ( +
+ {loading && } +
    + {!loading && + administrators.map((item, index) => ( +
  • +
    + + {item.role} + + +
    + +
    +
    +
    +
    +
    User
    +
    +
    + {dataFormatter.usersOneListFormatter(item.user)} +
    +
    +
    + +
    +
    Role
    +
    +
    {item.role}
    +
    +
    +
    +
  • + ))} + {!loading && administrators.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardAdministrators; diff --git a/frontend/src/components/Administrators/ListAdministrators.tsx b/frontend/src/components/Administrators/ListAdministrators.tsx new file mode 100644 index 0000000..c4a9395 --- /dev/null +++ b/frontend/src/components/Administrators/ListAdministrators.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + administrators: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListAdministrators = ({ + administrators, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_ADMINISTRATORS', + ); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + administrators.map((item) => ( + +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

User

+

+ {dataFormatter.usersOneListFormatter(item.user)} +

+
+ +
+

Role

+

{item.role}

+
+ + +
+
+ ))} + {!loading && administrators.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListAdministrators; diff --git a/frontend/src/components/Administrators/TableAdministrators.tsx b/frontend/src/components/Administrators/TableAdministrators.tsx new file mode 100644 index 0000000..6a78552 --- /dev/null +++ b/frontend/src/components/Administrators/TableAdministrators.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/administrators/administratorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureAdministratorsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListAdministrators from './ListAdministrators'; + +const perPage = 10; + +const TableSampleAdministrators = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + administrators, + loading, + count, + notify: administratorsNotify, + refetch, + } = useAppSelector((state) => state.administrators); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (administratorsNotify.showNotification) { + notify( + administratorsNotify.typeNotification, + administratorsNotify.textNotification, + ); + } + }, [administratorsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `administrators`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={administrators ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {administrators && Array.isArray(administrators) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleAdministrators; diff --git a/frontend/src/components/Administrators/configureAdministratorsCols.tsx b/frontend/src/components/Administrators/configureAdministratorsCols.tsx new file mode 100644 index 0000000..8c0be7f --- /dev/null +++ b/frontend/src/components/Administrators/configureAdministratorsCols.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_ADMINISTRATORS'); + + return [ + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('users'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'role', + headerName: 'Role', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx new file mode 100644 index 0000000..0a1c120 --- /dev/null +++ b/frontend/src/components/AsideMenu.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { MenuAsideItem } from '../interfaces'; +import AsideMenuLayer from './AsideMenuLayer'; +import OverlayLayer from './OverlayLayer'; + +type Props = { + menu: MenuAsideItem[]; + isAsideMobileExpanded: boolean; + isAsideLgActive: boolean; + onAsideLgClose: () => void; +}; + +export default function AsideMenu({ + isAsideMobileExpanded = false, + isAsideLgActive = false, + ...props +}: Props) { + return ( + <> + + {isAsideLgActive && ( + + )} + + ); +} diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx new file mode 100644 index 0000000..c5a8b8a --- /dev/null +++ b/frontend/src/components/AsideMenuItem.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useState } from 'react'; +import { mdiMinus, mdiPlus } from '@mdi/js'; +import BaseIcon from './BaseIcon'; +import Link from 'next/link'; +import { getButtonColor } from '../colors'; +import AsideMenuList from './AsideMenuList'; +import { MenuAsideItem } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; + +type Props = { + item: MenuAsideItem; + isDropdownList?: boolean; +}; + +const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { + const [isLinkActive, setIsLinkActive] = useState(false); + const [isDropdownActive, setIsDropdownActive] = useState(false); + + const asideMenuItemStyle = useAppSelector( + (state) => state.style.asideMenuItemStyle, + ); + const asideMenuDropdownStyle = useAppSelector( + (state) => state.style.asideMenuDropdownStyle, + ); + const asideMenuItemActiveStyle = useAppSelector( + (state) => state.style.asideMenuItemActiveStyle, + ); + const borders = useAppSelector((state) => state.style.borders); + const activeLinkColor = useAppSelector( + (state) => state.style.activeLinkColor, + ); + const activeClassAddon = + !item.color && isLinkActive ? asideMenuItemActiveStyle : ''; + + const { asPath, isReady } = useRouter(); + + useEffect(() => { + if (item.href && isReady) { + const linkPathName = new URL(item.href, location.href).pathname + '/'; + const activePathname = new URL(asPath, location.href).pathname; + + const activeView = activePathname.split('/')[1]; + const linkPathNameView = linkPathName.split('/')[1]; + + setIsLinkActive(linkPathNameView === activeView); + } + }, [item.href, isReady, asPath]); + + const asideMenuItemInnerContents = ( + <> + {item.icon && ( + + )} + + {item.label} + + {item.menu && ( + + )} + + ); + + const componentClass = [ + 'flex cursor-pointer py-1.5 ', + isDropdownList ? 'px-6 text-sm' : '', + item.color + ? getButtonColor(item.color, false, true) + : `${asideMenuItemStyle}`, + isLinkActive + ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` + : '', + ].join(' '); + + return ( +
  • + {item.withDevider &&
    } + {item.href && ( + + {asideMenuItemInnerContents} + + )} + {!item.href && ( +
    setIsDropdownActive(!isDropdownActive)} + > + {asideMenuItemInnerContents} +
    + )} + {item.menu && ( + + )} +
  • + ); +}; + +export default AsideMenuItem; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx new file mode 100644 index 0000000..6bfaf55 --- /dev/null +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { mdiLogout, mdiClose } from '@mdi/js'; +import BaseIcon from './BaseIcon'; +import AsideMenuList from './AsideMenuList'; +import { MenuAsideItem } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; +import Link from 'next/link'; + +type Props = { + menu: MenuAsideItem[]; + className?: string; + onAsideLgCloseClick: () => void; +}; + +export default function AsideMenuLayer({ + menu, + className = '', + ...props +}: Props) { + const asideStyle = useAppSelector((state) => state.style.asideStyle); + const asideBrandStyle = useAppSelector( + (state) => state.style.asideBrandStyle, + ); + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const darkMode = useAppSelector((state) => state.style.darkMode); + + const handleAsideLgCloseClick = (e: React.MouseEvent) => { + e.preventDefault(); + props.onAsideLgCloseClick(); + }; + + return ( + + ); +} diff --git a/frontend/src/components/AsideMenuList.tsx b/frontend/src/components/AsideMenuList.tsx new file mode 100644 index 0000000..1220c79 --- /dev/null +++ b/frontend/src/components/AsideMenuList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { MenuAsideItem } from '../interfaces'; +import AsideMenuItem from './AsideMenuItem'; +import { useAppSelector } from '../stores/hooks'; +import { hasPermission } from '../helpers/userPermissions'; + +type Props = { + menu: MenuAsideItem[]; + isDropdownList?: boolean; + className?: string; +}; + +export default function AsideMenuList({ + menu, + isDropdownList = false, + className = '', +}: Props) { + const { currentUser } = useAppSelector((state) => state.auth); + + if (!currentUser) return null; + + return ( +
      + {menu.map((item, index) => { + if (!hasPermission(currentUser, item.permissions)) return null; + + return ( + + ); + })} +
    + ); +} diff --git a/frontend/src/components/BaseButton.tsx b/frontend/src/components/BaseButton.tsx new file mode 100644 index 0000000..cb87f90 --- /dev/null +++ b/frontend/src/components/BaseButton.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import Link from 'next/link'; +import { getButtonColor } from '../colors'; +import BaseIcon from './BaseIcon'; +import type { ColorButtonKey } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + iconSize?: string | number; + href?: string; + target?: string; + type?: string; + color?: ColorButtonKey; + className?: string; + iconClassName?: string; + asAnchor?: boolean; + small?: boolean; + outline?: boolean; + active?: boolean; + disabled?: boolean; + roundedFull?: boolean; + onClick?: (e: React.MouseEvent) => void; +}; + +export default function BaseButton({ + label, + icon, + iconSize, + href, + target, + type, + color = 'white', + className = '', + iconClassName = '', + asAnchor = false, + small = false, + outline = false, + active = false, + disabled = false, + roundedFull = false, + onClick, +}: Props) { + const corners = useAppSelector((state) => state.style.corners); + const componentClass = [ + 'inline-flex', + 'justify-center', + 'items-center', + 'whitespace-nowrap', + 'focus:outline-none', + 'transition-colors', + 'focus:ring', + 'duration-150', + 'border', + disabled ? 'cursor-not-allowed' : 'cursor-pointer', + roundedFull ? 'rounded-full' : `${corners}`, + getButtonColor(color, outline, !disabled, active), + className, + ]; + + if (!label && icon) { + componentClass.push('p-1'); + } else if (small) { + componentClass.push('text-sm', roundedFull ? 'px-3 py-1' : 'p-1'); + } else { + componentClass.push('py-2', roundedFull ? 'px-6' : 'px-3'); + } + + if (disabled) { + componentClass.push(outline ? 'opacity-50' : 'opacity-70'); + } + + const componentClassString = componentClass.join(' '); + + const componentChildren = ( + <> + {icon && ( + + )} + {label && ( + {label} + )} + + ); + + if (href && !disabled) { + return ( + + {componentChildren} + + ); + } + + return React.createElement( + asAnchor ? 'a' : 'button', + { + className: componentClassString, + type: type ?? 'button', + target, + disabled, + onClick, + }, + componentChildren, + ); +} diff --git a/frontend/src/components/BaseButtons.tsx b/frontend/src/components/BaseButtons.tsx new file mode 100644 index 0000000..86eb456 --- /dev/null +++ b/frontend/src/components/BaseButtons.tsx @@ -0,0 +1,38 @@ +import { Children, cloneElement, ReactElement } from 'react'; +import type { ReactNode } from 'react'; + +type Props = { + type?: string; + mb?: string; + noWrap?: boolean; + classAddon?: string; + children: ReactNode; + className?: string; +}; + +const BaseButtons = ({ + type = 'justify-end', + mb = '-mb-3', + classAddon = 'mr-3 last:mr-0 mb-3', + noWrap = false, + children, + className, +}: Props) => { + return ( +
    + {Children.map(children, (child: ReactElement) => + child + ? cloneElement(child, { + className: `${classAddon} ${child.props.className}`, + }) + : null, + )} +
    + ); +}; + +export default BaseButtons; diff --git a/frontend/src/components/BaseDivider.tsx b/frontend/src/components/BaseDivider.tsx new file mode 100644 index 0000000..52e7f29 --- /dev/null +++ b/frontend/src/components/BaseDivider.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useAppSelector } from '../stores/hooks'; +type Props = { + navBar?: boolean; +}; + +export default function BaseDivider({ navBar = false }: Props) { + const borders = useAppSelector((state) => state.style.borders); + const classAddon = navBar + ? 'hidden lg:block lg:my-0.5 dark:border-dark-700' + : 'my-6 -mx-6 dark:border-dark-800'; + + return
    ; +} diff --git a/frontend/src/components/BaseIcon.tsx b/frontend/src/components/BaseIcon.tsx new file mode 100644 index 0000000..d26fe1c --- /dev/null +++ b/frontend/src/components/BaseIcon.tsx @@ -0,0 +1,39 @@ +import React, { ReactNode } from 'react'; + +type Props = { + path: string; + w?: string; + h?: string; + fill?: string; + size?: string | number | null; + className?: string; + children?: ReactNode; +}; + +export default function BaseIcon({ + path, + fill, + w = 'w-6', + h = 'h-6', + size = null, + className = '', + children, +}: Props) { + const iconSize = size ?? 16; + + return ( + + + + + {children} + + ); +} diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx new file mode 100644 index 0000000..5d3c01c --- /dev/null +++ b/frontend/src/components/BigCalendar.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { + Calendar, + Views, + momentLocalizer, + SlotInfo, + EventProps, +} from 'react-big-calendar'; +import moment from 'moment'; +import 'react-big-calendar/lib/css/react-big-calendar.css'; +import ListActionsPopover from './ListActionsPopover'; +import Link from 'next/link'; + +import { useAppSelector } from '../stores/hooks'; +import { hasPermission } from '../helpers/userPermissions'; + +const localizer = momentLocalizer(moment); + +type TEvent = { + id: string; + title: string; + start: Date; + end: Date; +}; + +type Props = { + events: any[]; + handleDeleteAction: (id: string) => void; + handleCreateEventAction: (slotInfo: SlotInfo) => void; + onDateRangeChange: (range: { start: string; end: string }) => void; + entityName: string; + showField: string; + pathEdit?: string; + pathView?: string; + 'start-data-key': string; + 'end-data-key': string; +}; + +const BigCalendar = ({ + events, + handleDeleteAction, + handleCreateEventAction, + onDateRangeChange, + entityName, + showField, + pathEdit, + pathView, + 'start-data-key': startDataKey, + 'end-data-key': endDataKey, +}: Props) => { + const [myEvents, setMyEvents] = useState([]); + const prevRange = useRef<{ start: string; end: string }>(); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = + currentUser && + hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`); + const hasCreatePermission = + currentUser && + hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`); + + const { defaultDate, scrollToTime } = useMemo( + () => ({ + defaultDate: new Date(), + scrollToTime: new Date(1970, 1, 1, 6), + }), + [], + ); + + useEffect(() => { + if (!events || !Array.isArray(events) || !events?.length) return; + + const formattedEvents = events.map((event) => ({ + ...event, + start: new Date(event[startDataKey]), + end: new Date(event[endDataKey]), + title: event[showField], + })); + + setMyEvents(formattedEvents); + }, [endDataKey, events, startDataKey, showField]); + + const onRangeChange = (range: Date[] | { start: Date; end: Date }) => { + const newRange = { start: '', end: '' }; + const format = 'YYYY-MM-DDTHH:mm'; + + if (Array.isArray(range)) { + newRange.start = moment(range[0]).format(format); + newRange.end = moment(range[range.length - 1]).format(format); + } else { + newRange.start = moment(range.start).format(format); + newRange.end = moment(range.end).format(format); + } + + if (newRange.start === newRange.end) { + newRange.end = moment(newRange.end).add(1, 'days').format(format); + } + + // check if the range fits in the previous range + if ( + prevRange.current && + prevRange.current.start <= newRange.start && + prevRange.current.end >= newRange.end + ) { + return; + } + + prevRange.current = { start: newRange.start, end: newRange.end }; + onDateRangeChange(newRange); + }; + + return ( +
    + ( + + ), + }} + /> +
    + ); +}; + +const MyCustomEvent = ( + props: { + onDelete: (id: string) => void; + hasUpdatePermission: boolean; + pathEdit?: string; + pathView?: string; + } & EventProps, +) => { + const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = + props; + + return ( +
    + + {title} + + +
    + ); +}; + +export default BigCalendar; diff --git a/frontend/src/components/CardBox.tsx b/frontend/src/components/CardBox.tsx new file mode 100644 index 0000000..09b11aa --- /dev/null +++ b/frontend/src/components/CardBox.tsx @@ -0,0 +1,70 @@ +import React, { ReactNode } from 'react'; +import CardBoxComponentBody from './CardBoxComponentBody'; +import CardBoxComponentFooter from './CardBoxComponentFooter'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + rounded?: string; + flex?: string; + className?: string; + hasComponentLayout?: boolean; + cardBoxClassName?: string; + hasTable?: boolean; + isHoverable?: boolean; + isModal?: boolean; + children: ReactNode; + footer?: ReactNode; + isList?: boolean; + id?: string; + onClick?: (e: React.MouseEvent) => void; +}; + +export default function CardBox({ + rounded = 'rounded', + flex = 'flex-col', + className = '', + hasComponentLayout = false, + cardBoxClassName = '', + hasTable = false, + isHoverable = false, + isList = false, + isModal = false, + children, + footer, + id = '', + onClick, +}: Props) { + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const componentClass = [ + `flex dark:border-dark-700 dark:bg-dark-900`, + className, + corners !== 'rounded-full' ? corners : 'rounded-3xl', + flex, + isList ? '' : `${cardsStyle}`, + hasTable ? '' : `border-dark-700 dark:border-dark-700`, + ]; + + if (isHoverable) { + componentClass.push('hover:shadow-lg transition-shadow duration-500'); + } + + return React.createElement( + 'div', + { className: componentClass.join(' '), onClick }, + hasComponentLayout ? ( + children + ) : ( + <> + + {children} + + {footer && {footer}} + + ), + ); +} diff --git a/frontend/src/components/CardBoxComponentBody.tsx b/frontend/src/components/CardBoxComponentBody.tsx new file mode 100644 index 0000000..12448d8 --- /dev/null +++ b/frontend/src/components/CardBoxComponentBody.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; + +type Props = { + noPadding?: boolean; + className?: string; + children?: ReactNode; + id?: string; +}; + +export default function CardBoxComponentBody({ + noPadding = false, + className, + children, + id, +}: Props) { + return ( +
    + {children} +
    + ); +} diff --git a/frontend/src/components/CardBoxComponentEmpty.tsx b/frontend/src/components/CardBoxComponentEmpty.tsx new file mode 100644 index 0000000..c9072bb --- /dev/null +++ b/frontend/src/components/CardBoxComponentEmpty.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const CardBoxComponentEmpty = () => { + return ( +
    +

    Nothing's here…

    +
    + ); +}; + +export default CardBoxComponentEmpty; diff --git a/frontend/src/components/CardBoxComponentFooter.tsx b/frontend/src/components/CardBoxComponentFooter.tsx new file mode 100644 index 0000000..184a058 --- /dev/null +++ b/frontend/src/components/CardBoxComponentFooter.tsx @@ -0,0 +1,10 @@ +import React, { ReactNode } from 'react'; + +type Props = { + className?: string; + children?: ReactNode; +}; + +export default function CardBoxComponentFooter({ className, children }: Props) { + return
    {children}
    ; +} diff --git a/frontend/src/components/CardBoxComponentTitle.tsx b/frontend/src/components/CardBoxComponentTitle.tsx new file mode 100644 index 0000000..20990e6 --- /dev/null +++ b/frontend/src/components/CardBoxComponentTitle.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; + +type Props = { + title: string; + children?: ReactNode; +}; + +const CardBoxComponentTitle = ({ title, children }: Props) => { + return ( +
    +

    {title}

    + {children} +
    + ); +}; + +export default CardBoxComponentTitle; diff --git a/frontend/src/components/CardBoxModal.tsx b/frontend/src/components/CardBoxModal.tsx new file mode 100644 index 0000000..c87c0c5 --- /dev/null +++ b/frontend/src/components/CardBoxModal.tsx @@ -0,0 +1,75 @@ +import { mdiClose } from '@mdi/js'; +import { ReactNode } from 'react'; +import type { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import BaseButtons from './BaseButtons'; +import CardBox from './CardBox'; +import CardBoxComponentTitle from './CardBoxComponentTitle'; +import OverlayLayer from './OverlayLayer'; + +type Props = { + title: string; + buttonColor: ColorButtonKey; + buttonLabel: string; + isActive: boolean; + children: ReactNode; + onConfirm: () => void; + onCancel?: () => void; +}; + +const CardBoxModal = ({ + title, + buttonColor, + buttonLabel, + isActive, + children, + onConfirm, + onCancel, +}: Props) => { + if (!isActive) { + return null; + } + + const footer = ( + + + {!!onCancel && ( + + )} + + ); + + return ( + + + + {!!onCancel && ( + + )} + + +
    {children}
    +
    +
    + ); +}; + +export default CardBoxModal; diff --git a/frontend/src/components/ChartLineSample/config.ts b/frontend/src/components/ChartLineSample/config.ts new file mode 100644 index 0000000..c29cbdd --- /dev/null +++ b/frontend/src/components/ChartLineSample/config.ts @@ -0,0 +1,54 @@ +export const chartColors = { + default: { + primary: '#00D1B2', + info: '#209CEE', + danger: '#FF3860', + }, +}; + +const randomChartData = (n: number) => { + const data = []; + + for (let i = 0; i < n; i++) { + data.push(Math.round(Math.random() * 200)); + } + + return data; +}; + +const datasetObject = (color: string, points: number) => { + return { + fill: false, + borderColor: chartColors.default[color], + borderWidth: 2, + borderDash: [], + borderDashOffset: 0.0, + pointBackgroundColor: chartColors.default[color], + pointBorderColor: 'rgba(255,255,255,0)', + pointHoverBackgroundColor: chartColors.default[color], + pointBorderWidth: 20, + pointHoverRadius: 4, + pointHoverBorderWidth: 15, + pointRadius: 4, + data: randomChartData(points), + tension: 0.5, + cubicInterpolationMode: 'default', + }; +}; + +export const sampleChartData = (points = 9) => { + const labels = []; + + for (let i = 1; i <= points; i++) { + labels.push(`0${i}`); + } + + return { + labels, + datasets: [ + datasetObject('primary', points), + datasetObject('info', points), + datasetObject('danger', points), + ], + }; +}; diff --git a/frontend/src/components/ChartLineSample/index.tsx b/frontend/src/components/ChartLineSample/index.tsx new file mode 100644 index 0000000..0761549 --- /dev/null +++ b/frontend/src/components/ChartLineSample/index.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { + Chart, + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; + +Chart.register( + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, +); + +const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + display: false, + }, + x: { + display: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, +}; + +const ChartLineSample = ({ data }) => { + return ; +}; + +export default ChartLineSample; diff --git a/frontend/src/components/ClickOutside.tsx b/frontend/src/components/ClickOutside.tsx new file mode 100644 index 0000000..d878647 --- /dev/null +++ b/frontend/src/components/ClickOutside.tsx @@ -0,0 +1,29 @@ +import React, { useCallback, useEffect, useRef } from 'react'; + +const ClickOutside = ({ children, onClickOutside, excludedElements }) => { + const wrapperRef = useRef(null); + + const handleClickOutside = useCallback( + (event) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(event.target) && + !excludedElements.some((el) => el.current.contains(event.target)) + ) { + onClickOutside(); + } + }, + [wrapperRef, onClickOutside, ...excludedElements], + ); + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleClickOutside]); + + return
    {children}
    ; +}; + +export default ClickOutside; diff --git a/frontend/src/components/Clients/CardClients.tsx b/frontend/src/components/Clients/CardClients.tsx new file mode 100644 index 0000000..2166329 --- /dev/null +++ b/frontend/src/components/Clients/CardClients.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + clients: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardClients = ({ + clients, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLIENTS'); + + return ( +
    + {loading && } +
      + {!loading && + clients.map((item, index) => ( +
    • +
      + + {item.mood} + + +
      + +
      +
      +
      +
      +
      User
      +
      +
      + {dataFormatter.usersOneListFormatter(item.user)} +
      +
      +
      + +
      +
      Mood
      +
      +
      {item.mood}
      +
      +
      + +
      +
      + Sessions +
      +
      +
      + {dataFormatter + .sessionsManyListFormatter(item.sessions) + .join(', ')} +
      +
      +
      +
      +
    • + ))} + {!loading && clients.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardClients; diff --git a/frontend/src/components/Clients/ListClients.tsx b/frontend/src/components/Clients/ListClients.tsx new file mode 100644 index 0000000..9fefbd5 --- /dev/null +++ b/frontend/src/components/Clients/ListClients.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + clients: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListClients = ({ + clients, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLIENTS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + clients.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    User

    +

    + {dataFormatter.usersOneListFormatter(item.user)} +

    +
    + +
    +

    Mood

    +

    {item.mood}

    +
    + +
    +

    Sessions

    +

    + {dataFormatter + .sessionsManyListFormatter(item.sessions) + .join(', ')} +

    +
    + + +
    +
    + ))} + {!loading && clients.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListClients; diff --git a/frontend/src/components/Clients/TableClients.tsx b/frontend/src/components/Clients/TableClients.tsx new file mode 100644 index 0000000..952d8f4 --- /dev/null +++ b/frontend/src/components/Clients/TableClients.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureClientsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListClients from './ListClients'; + +const perPage = 10; + +const TableSampleClients = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + clients, + loading, + count, + notify: clientsNotify, + refetch, + } = useAppSelector((state) => state.clients); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (clientsNotify.showNotification) { + notify(clientsNotify.typeNotification, clientsNotify.textNotification); + } + }, [clientsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `clients`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={clients ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {clients && Array.isArray(clients) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleClients; diff --git a/frontend/src/components/Clients/configureClientsCols.tsx b/frontend/src/components/Clients/configureClientsCols.tsx new file mode 100644 index 0000000..3e119c8 --- /dev/null +++ b/frontend/src/components/Clients/configureClientsCols.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CLIENTS'); + + return [ + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('users'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'mood', + headerName: 'Mood', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'sessions', + headerName: 'Sessions', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + dataFormatter.sessionsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/DataGridMultiSelect.tsx b/frontend/src/components/DataGridMultiSelect.tsx new file mode 100644 index 0000000..bb82434 --- /dev/null +++ b/frontend/src/components/DataGridMultiSelect.tsx @@ -0,0 +1,55 @@ +import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid'; +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { MenuItem, Select } from '@mui/material'; + +interface Props { + entityName: string; +} + +const DataGridMultiSelect = (props: GridRenderEditCellParams & Props) => { + const { id, value, field, entityName } = props; + const apiRef = useGridApiContext(); + const [options, setOptions] = useState([]); + + async function callApi(entityName: string) { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } + + useEffect(() => { + callApi(entityName).then((data) => { + setOptions(data); + }); + }, []); + + const handleChange = (event) => { + const eventValue = event.target.value; // The new value entered by the user + + const newValue = + typeof eventValue === 'string' ? value.split(',') : eventValue; + + apiRef.current.setEditCellValue({ + id, + field, + value: newValue.filter((x) => x !== ''), + }); + }; + + return ( + + ); +}; + +export default DataGridMultiSelect; diff --git a/frontend/src/components/DragDropFilePicker.tsx b/frontend/src/components/DragDropFilePicker.tsx new file mode 100644 index 0000000..821570d --- /dev/null +++ b/frontend/src/components/DragDropFilePicker.tsx @@ -0,0 +1,124 @@ +import React, { ChangeEvent, useEffect, useState } from 'react'; +import BaseIcon from './BaseIcon'; +import { mdiFileUploadOutline } from '@mdi/js'; + +type Props = { + file: File | null; + setFile: (file: File) => void; + formats?: string; +}; + +const DragDropFilePicker = ({ file, setFile, formats = '' }: Props) => { + const [highlight, setHighlight] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const fileInput = React.createRef(); + + useEffect(() => { + if (!file && fileInput) fileInput.current.value = ''; + }, [file, fileInput]); + + function onFilesAdded(files: FileList | null) { + if (files && files[0]) { + const newFile = files[0]; + const fileExtension = newFile.name.split('.').pop().toLowerCase(); + + if (formats.includes(fileExtension) || !formats) { + setFile(newFile); + setErrorMessage(''); + } else { + setErrorMessage(`Allowed formats: ${formats}`); + } + } + } + + function onDragOver(e) { + e.preventDefault(); + setHighlight(true); + } + + function onDragLeave() { + setHighlight(false); + } + + function onDrop(e) { + e.preventDefault(); + + const files = e.dataTransfer.files; + + onFilesAdded(files); + setHighlight(false); + } + + const onClear = () => { + setFile(null); + setErrorMessage(''); + }; + + return ( +
    + +
    + ); +}; + +export default DragDropFilePicker; diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx new file mode 100644 index 0000000..0f6b2b5 --- /dev/null +++ b/frontend/src/components/FooterBar.tsx @@ -0,0 +1,34 @@ +import React, { ReactNode } from 'react'; +import { containerMaxW } from '../config'; +import Logo from './Logo'; + +type Props = { + children?: ReactNode; +}; + +export default function FooterBar({ children }: Props) { + const year = new Date().getFullYear(); + + return ( + + ); +} diff --git a/frontend/src/components/FormCheckRadio.tsx b/frontend/src/components/FormCheckRadio.tsx new file mode 100644 index 0000000..17c00ea --- /dev/null +++ b/frontend/src/components/FormCheckRadio.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; + +type Props = { + children: ReactNode; + type: 'checkbox' | 'radio' | 'switch'; + label?: string; + className?: string; +}; + +const FormCheckRadio = (props: Props) => { + return ( + + ); +}; + +export default FormCheckRadio; diff --git a/frontend/src/components/FormCheckRadioGroup.tsx b/frontend/src/components/FormCheckRadioGroup.tsx new file mode 100644 index 0000000..04fa072 --- /dev/null +++ b/frontend/src/components/FormCheckRadioGroup.tsx @@ -0,0 +1,24 @@ +import { Children, cloneElement, ReactElement, ReactNode } from 'react'; + +type Props = { + isColumn?: boolean; + children: ReactNode; +}; + +const FormCheckRadioGroup = (props: Props) => { + return ( +
    + {Children.map(props.children, (child: ReactElement) => + cloneElement(child, { + className: `mr-6 mb-3 last:mr-0 ${child.props.className}`, + }), + )} +
    + ); +}; + +export default FormCheckRadioGroup; diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx new file mode 100644 index 0000000..48e7be0 --- /dev/null +++ b/frontend/src/components/FormField.tsx @@ -0,0 +1,92 @@ +import { Children, cloneElement, ReactElement, ReactNode } from 'react'; +import BaseIcon from './BaseIcon'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + labelFor?: string; + help?: string; + icons?: string[] | null[]; + isBorderless?: boolean; + isTransparent?: boolean; + hasTextareaHeight?: boolean; + children: ReactNode; + disabled?: boolean; + borderButtom?: boolean; + diversity?: boolean; + websiteBg?: boolean; +}; + +const FormField = ({ icons = [], ...props }: Props) => { + const childrenCount = Children.count(props.children); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const corners = useAppSelector((state) => state.style.corners); + const bgWebsiteColor = useAppSelector((state) => state.style.bgLayoutColor); + let elementWrapperClass = ''; + + switch (childrenCount) { + case 2: + elementWrapperClass = 'grid grid-cols-1 gap-3 md:grid-cols-2'; + break; + case 3: + elementWrapperClass = 'grid grid-cols-1 gap-3 md:grid-cols-3'; + } + + const controlClassName = [ + `px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`, + `${focusRing}`, + props.hasTextareaHeight ? 'h-24' : 'h-12', + props.isBorderless ? 'border-0' : 'border', + props.isTransparent + ? 'bg-transparent' + : `${props.websiteBg ? `${bgWebsiteColor} ` : bgColor} dark:bg-dark-800`, + props.disabled ? 'bg-gray-200 text-gray-100 dark:bg-dark-900 disabled' : '', + props.borderButtom + ? `border-0 border-b ${ + props.diversity + ? 'placeholder-primaryText border-primaryText' + : ' placeholder-white border-white ' + } rounded-none focus:ring-0` + : '', + ].join(' '); + + return ( +
    + {props.label && ( + + )} +
    + {Children.map(props.children, (child: ReactElement, index) => ( +
    + {cloneElement(child, { + className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`, + })} + {icons[index] && ( + + )} +
    + ))} +
    + {props.help && ( +
    + {props.help} +
    + )} +
    + ); +}; + +export default FormField; diff --git a/frontend/src/components/FormFilePicker.tsx b/frontend/src/components/FormFilePicker.tsx new file mode 100644 index 0000000..7be6c35 --- /dev/null +++ b/frontend/src/components/FormFilePicker.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import FileUploader from './Uploaders/UploadService'; +import { mdiReload } from '@mdi/js'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + accept?: string; + color: ColorButtonKey; + isRoundIcon?: boolean; + path: string; + schema: object; + field: any; + form: any; +}; + +const FormFilePicker = ({ + label, + icon, + accept, + color, + isRoundIcon, + path, + schema, + form, + field, +}: Props) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + let cornersRight; + if (corners === 'rounded') { + cornersRight = 'rounded-r'; + } else if (corners === 'rounded-lg') { + cornersRight = 'rounded-r-lg'; + } else if (corners === 'rounded-full') { + cornersRight = 'rounded-r-3xl'; + } else { + cornersRight = ''; + } + + useEffect(() => { + if (field.value) { + setFile(field.value[0]); + } + }, [field.value]); + const handleFileChange = async (event) => { + const file = event.currentTarget.files[0]; + setFile(file); + + FileUploader.validate(file, schema); + setLoading(true); + const remoteFile = await FileUploader.upload(path, file, schema); + + form.setFieldValue(field.name, [{ ...remoteFile }]); + setLoading(false); + }; + + const showFilename = !isRoundIcon && file; + + return ( +
    + + {showFilename && !loading && ( +
    + + {file.name} + +
    + )} +
    + ); +}; + +export default FormFilePicker; diff --git a/frontend/src/components/FormImagePicker.tsx b/frontend/src/components/FormImagePicker.tsx new file mode 100644 index 0000000..9c0b175 --- /dev/null +++ b/frontend/src/components/FormImagePicker.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import ImagesUploader from './Uploaders/ImagesUploader'; +import FileUploader from './Uploaders/UploadService'; +import { mdiReload } from '@mdi/js'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + accept?: string; + color: ColorButtonKey; + isRoundIcon?: boolean; + path: string; + schema: object; + field: any; + form: any; +}; + +const FormImagePicker = ({ + label, + icon, + accept, + color, + isRoundIcon, + path, + schema, + form, + field, +}: Props) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + let cornersRight; + if (corners === 'rounded') { + cornersRight = 'rounded-r'; + } else if (corners === 'rounded-lg') { + cornersRight = 'rounded-r-lg'; + } else if (corners === 'rounded-full') { + cornersRight = 'rounded-r-3xl'; + } else { + cornersRight = ''; + } + + useEffect(() => { + if (field.value) { + setFile(field.value[0]); + } + }, [field.value]); + const handleFileChange = async (event) => { + const file = event.currentTarget.files[0]; + setFile(file); + + FileUploader.validate(file, schema); + setLoading(true); + const remoteFile = await FileUploader.upload(path, file, schema); + + form.setFieldValue(field.name, [{ ...remoteFile }]); + setLoading(false); + }; + + const showFilename = !isRoundIcon && file; + + return ( +
    + + {showFilename && !loading && ( +
    + + {file.name} + +
    + )} +
    + ); +}; + +export default FormImagePicker; diff --git a/frontend/src/components/IconRounded.tsx b/frontend/src/components/IconRounded.tsx new file mode 100644 index 0000000..7ec5864 --- /dev/null +++ b/frontend/src/components/IconRounded.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ColorKey } from '../interfaces'; +import { colorsBgLight, colorsText } from '../colors'; +import BaseIcon from './BaseIcon'; + +type Props = { + icon: string; + color: ColorKey; + w?: string; + h?: string; + bg?: boolean; + className?: string; +}; + +export default function IconRounded({ + icon, + color, + w = 'w-12', + h = 'h-12', + bg = false, + className = '', +}: Props) { + const classAddon = bg + ? colorsBgLight[color] + : `${colorsText[color]} bg-gray-50 dark:bg-slate-800`; + + return ( + + ); +} diff --git a/frontend/src/components/ImageField.tsx b/frontend/src/components/ImageField.tsx new file mode 100644 index 0000000..c575026 --- /dev/null +++ b/frontend/src/components/ImageField.tsx @@ -0,0 +1,51 @@ +/* eslint-disable @next/next/no-img-element */ +// Why disabled: +// avatars.dicebear.com provides svg avatars +// next/image needs dangerouslyAllowSVG option for that + +import React, { ReactNode } from 'react'; +import { mdiImageOutline } from '@mdi/js'; +import BaseIcon from './BaseIcon'; + +type Props = { + name: string; + image?: object | null; + api?: string; + className?: string; + imageClassName?: string; + children?: ReactNode; +}; + +export default function ImageField({ + name, + image, + className = '', + imageClassName = '', + children, +}: Props) { + const imageSrc = image && image[0] ? `${image[0].publicUrl}` : ''; + + return ( +
    + {imageSrc ? ( + {name} + ) : ( +
    + +
    + )} + + {children} +
    + ); +} diff --git a/frontend/src/components/IntroGuide.tsx b/frontend/src/components/IntroGuide.tsx new file mode 100644 index 0000000..7271c83 --- /dev/null +++ b/frontend/src/components/IntroGuide.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Steps, Hints } from 'intro.js-react'; +import { useRouter } from 'next/router'; +interface IntroGuideProps { + steps: Array<{ + element: string; + intro: string; + position?: string; + }>; + disableInteraction?: boolean; + stepsEnabled: boolean; + stepsName: string; + onExit: () => void; +} + +const IntroGuide: React.FC = ({ + steps, + stepsEnabled, + onExit, + stepsName, +}) => { + const router = useRouter(); + const handleStepChange = (stepIndex: number) => { + if (stepIndex === 7 && stepsName === 'appSteps') { + onExit(); + router.push('/users/users-list/'); + } else if (stepIndex === 2 && stepsName === 'usersSteps') { + onExit(); + router.push('/roles/roles-list/'); + } + }; + + const handleExit = () => { + localStorage.setItem(`completed_${stepsName}`, 'true'); + onExit(); + }; + return ( + <> + + + ); +}; + +export default IntroGuide; diff --git a/frontend/src/components/KanbanBoard/KanbanBoard.tsx b/frontend/src/components/KanbanBoard/KanbanBoard.tsx new file mode 100644 index 0000000..76d6849 --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanBoard.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import KanbanColumn from './KanbanColumn'; +import { AsyncThunk } from '@reduxjs/toolkit'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +type Props = { + columns: Array<{ id: string; label: string }>; + filtersQuery: string; + entityName: string; + columnFieldName: string; + showFieldName: string; + deleteThunk: AsyncThunk; + updateThunk: AsyncThunk; +}; + +const KanbanBoard = ({ + columns, + entityName, + columnFieldName, + filtersQuery, + showFieldName, + deleteThunk, + updateThunk, +}: Props) => { + return ( +
    + + {columns.map((column) => ( + + ))} + +
    + ); +}; + +export default KanbanBoard; diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx new file mode 100644 index 0000000..ed98fe6 --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Link from 'next/link'; +import moment from 'moment'; +import ListActionsPopover from '../ListActionsPopover'; +import { DragSourceMonitor, useDrag } from 'react-dnd'; + +type Props = { + item: any; + column: { id: string; label: string }; + entityName: string; + showFieldName: string; + setItemIdToDelete: (id: string) => void; +}; + +const KanbanCard = ({ + item, + entityName, + showFieldName, + setItemIdToDelete, + column, +}: Props) => { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: 'box', + item: { item, column }, + collect: (monitor: DragSourceMonitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [item], + ); + + return ( +
    +
    + + {item[showFieldName] ?? 'No data'} + +
    +
    +

    {moment(item.createdAt).format('MMM DD hh:mm a')}

    + setItemIdToDelete(id)} + hasUpdatePermission={true} + className={'w-2 h-2 text-white'} + iconClassName={'w-5'} + /> +
    +
    + ); +}; + +export default KanbanCard; diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx new file mode 100644 index 0000000..a050daa --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanColumn.tsx @@ -0,0 +1,215 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import Axios from 'axios'; +import CardBox from '../CardBox'; +import CardBoxModal from '../CardBoxModal'; +import { AsyncThunk } from '@reduxjs/toolkit'; +import { useDrop } from 'react-dnd'; +import KanbanCard from './KanbanCard'; + +type Props = { + column: { id: string; label: string }; + entityName: string; + columnFieldName: string; + showFieldName: string; + filtersQuery: any; + deleteThunk: AsyncThunk; + updateThunk: AsyncThunk; +}; + +type DropResult = { + sourceColumn: { id: string; label: string }; + item: any; +}; + +const perPage = 10; + +const KanbanColumn = ({ + column, + entityName, + columnFieldName, + showFieldName, + filtersQuery, + deleteThunk, + updateThunk, +}: Props) => { + const [currentPage, setCurrentPage] = useState(0); + const [count, setCount] = useState(0); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [itemIdToDelete, setItemIdToDelete] = useState(''); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const listInnerRef = useRef(); + const dispatch = useAppDispatch(); + + const [{ dropResult }, drop] = useDrop< + { + item: any; + column: { + id: string; + label: string; + }; + }, + unknown, + { + dropResult: DropResult; + } + >( + () => ({ + accept: 'box', + drop: ({ + item, + column: sourceColumn, + }: { + item: any; + column: { id: string; label: string }; + }) => { + if (sourceColumn.id === column.id) return; + + dispatch( + updateThunk({ + id: item.id, + data: { + [columnFieldName]: column.id, + }, + }), + ).then((res) => { + console.log('res', res); + setData((prevState) => (prevState ? [...prevState, item] : [item])); + setCount((prevState) => prevState + 1); + }); + + return { sourceColumn, item }; + }, + collect: (monitor) => ({ + dropResult: monitor.getDropResult(), + }), + }), + [], + ); + + const loadData = useCallback( + (page: number, filters = '') => { + const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`; + setLoading(true); + Axios.get(`${entityName}${query}`) + .then((res) => { + setData((prevState) => + page === 0 ? res.data.rows : [...prevState, ...res.data.rows], + ); + setCount(res.data.count); + setCurrentPage(page); + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + setLoading(false); + }); + }, + [currentUser, column], + ); + + useEffect(() => { + if (!currentUser) return; + loadData(0, filtersQuery); + }, [currentUser, loadData, filtersQuery]); + + useEffect(() => { + loadData(0, filtersQuery); + }, [loadData, filtersQuery]); + + useEffect(() => { + if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) { + setData((prevState) => + prevState.filter((item) => item.id !== dropResult.item.id), + ); + setCount((prevState) => prevState - 1); + } + }, [dropResult]); + + const onScroll = () => { + if (listInnerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current; + if (Math.floor(scrollTop + clientHeight) === scrollHeight) { + if (data.length < count && !loading) { + loadData(currentPage + 1, filtersQuery); + } + } + } + }; + + const onDeleteConfirm = () => { + if (!itemIdToDelete) return; + + dispatch(deleteThunk(itemIdToDelete)) + .then((res) => { + if (res.meta.requestStatus === 'fulfilled') { + setItemIdToDelete(''); + loadData(0, filtersQuery); + } + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + setItemIdToDelete(''); + }); + }; + + return ( + <> + +
    +

    {column.label}

    +

    {count}

    +
    +
    { + drop(node); + listInnerRef.current = node; + }} + className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'} + onScroll={onScroll} + > + {data?.map((item) => ( + + ))} + {!data?.length && ( +

    + No data +

    + )} +
    +
    + setItemIdToDelete('')} + > +

    Are you sure you want to delete this item?

    +
    + + ); +}; + +export default KanbanColumn; diff --git a/frontend/src/components/ListActionsPopover.tsx b/frontend/src/components/ListActionsPopover.tsx new file mode 100644 index 0000000..dfa5db0 --- /dev/null +++ b/frontend/src/components/ListActionsPopover.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Link from 'next/link'; +import Button from '@mui/material/Button'; +import BaseIcon from './BaseIcon'; +import { + mdiDotsVertical, + mdiEye, + mdiPencilOutline, + mdiTrashCan, +} from '@mdi/js'; +import Popover from '@mui/material/Popover'; +import { IconButton } from '@mui/material'; + +type Props = { + itemId: string; + onDelete: (id: string) => void; + hasUpdatePermission: boolean; + className?: string; + iconClassName?: string; + pathEdit: string; + pathView: string; +}; + +const ListActionsPopover = ({ + itemId, + onDelete, + hasUpdatePermission, + className, + iconClassName, + pathEdit, + pathView, +}: Props) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + const linkView = pathView; + const linkEdit = pathEdit; + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const id = open ? 'simple-popover' : undefined; + + return ( + <> + + + + +
    + + {hasUpdatePermission && ( + + )} + {hasUpdatePermission && ( + + )} +
    +
    + + ); +}; + +export default ListActionsPopover; diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..5c21192 --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +const LoadingSpinner = () => { + return ( +
    +
    +
    +
    +
    +
    + ); +}; + +export default LoadingSpinner; diff --git a/frontend/src/components/Logo/index.tsx b/frontend/src/components/Logo/index.tsx new file mode 100644 index 0000000..7d9123c --- /dev/null +++ b/frontend/src/components/Logo/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +type Props = { + className?: string; +}; + +export default function Logo({ className = '' }: Props) { + return ( + {'Flatlogic + ); +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx new file mode 100644 index 0000000..3e2fe55 --- /dev/null +++ b/frontend/src/components/NavBar.tsx @@ -0,0 +1,65 @@ +import React, { ReactNode, useState, useEffect } from 'react'; +import { mdiClose, mdiDotsVertical } from '@mdi/js'; +import { containerMaxW } from '../config'; +import BaseIcon from './BaseIcon'; +import NavBarItemPlain from './NavBarItemPlain'; +import NavBarMenuList from './NavBarMenuList'; +import { MenuNavBarItem } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + menu: MenuNavBarItem[]; + className: string; + children: ReactNode; +}; + +export default function NavBar({ menu, className = '', children }: Props) { + const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + + useEffect(() => { + const handleScroll = () => { + const scrolled = window.scrollY > 0; + setIsScrolled(scrolled); + }; + window.addEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); + + const handleMenuNavBarToggleClick = () => { + setIsMenuNavBarActive(!isMenuNavBarActive); + }; + + return ( + + ); +} diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx new file mode 100644 index 0000000..0cbf030 --- /dev/null +++ b/frontend/src/components/NavBarItem.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useRef } 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'; +import UserAvatarCurrentUser from './UserAvatarCurrentUser'; +import NavBarMenuList from './NavBarMenuList'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { MenuNavBarItem } from '../interfaces'; +import { setDarkMode } from '../stores/styleSlice'; +import { logoutUser } from '../stores/authSlice'; +import { useRouter } from 'next/router'; +import ClickOutside from './ClickOutside'; + +type Props = { + item: MenuNavBarItem; +}; + +export default function NavBarItem({ item }: Props) { + const router = useRouter(); + const dispatch = useAppDispatch(); + const excludedRef = useRef(null); + + const navBarItemLabelActiveColorStyle = useAppSelector( + (state) => state.style.navBarItemLabelActiveColorStyle, + ); + const navBarItemLabelStyle = useAppSelector( + (state) => state.style.navBarItemLabelStyle, + ); + const navBarItemLabelHoverStyle = useAppSelector( + (state) => state.style.navBarItemLabelHoverStyle, + ); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + + const userName = `${currentUser?.firstName ? currentUser?.firstName : ''} ${ + currentUser?.lastName ? currentUser?.lastName : '' + }`; + + const [isDropdownActive, setIsDropdownActive] = useState(false); + + useEffect(() => { + return () => setIsDropdownActive(false); + }, [router.pathname]); + + const componentClass = [ + 'block lg:flex items-center relative cursor-pointer', + isDropdownActive + ? `${navBarItemLabelActiveColorStyle} dark:text-slate-400` + : `${navBarItemLabelStyle} dark:text-white dark:hover:text-slate-400 ${navBarItemLabelHoverStyle}`, + item.menu ? 'lg:py-2 lg:px-3' : 'py-2 px-3', + item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '', + ].join(' '); + + const itemLabel = item.isCurrentUser ? userName : item.label; + + const handleMenuClick = () => { + if (item.menu) { + setIsDropdownActive(!isDropdownActive); + } + + if (item.isToggleLightDark) { + dispatch(setDarkMode(null)); + } + + if (item.isLogout) { + dispatch(logoutUser()); + router.push('/login'); + } + }; + + const getItemId = (label) => { + switch (label) { + case 'Light/Dark': + return 'themeToggle'; + case 'Log out': + return 'logout'; + default: + return undefined; + } + }; + + const NavBarItemComponentContents = ( + <> +
    + {item.icon && ( + + )} + + {itemLabel} + + {item.isCurrentUser && ( + + )} + {item.menu && ( + + )} +
    + {item.menu && ( +
    + setIsDropdownActive(false)} + excludedElements={[excludedRef]} + > + + +
    + )} + + ); + + if (item.isDivider) { + return ; + } + + if (item.href) { + return ( + + {NavBarItemComponentContents} + + ); + } + + return ( +
    + {NavBarItemComponentContents} +
    + ); +} diff --git a/frontend/src/components/NavBarItemPlain.tsx b/frontend/src/components/NavBarItemPlain.tsx new file mode 100644 index 0000000..e9b7748 --- /dev/null +++ b/frontend/src/components/NavBarItemPlain.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + display?: string; + useMargin?: boolean; + children: ReactNode; + onClick?: (e: React.MouseEvent) => void; +}; + +export default function NavBarItemPlain({ + display = 'flex', + useMargin = false, + onClick, + children, +}: Props) { + const navBarItemLabelStyle = useAppSelector( + (state) => state.style.navBarItemLabelStyle, + ); + const navBarItemLabelHoverStyle = useAppSelector( + (state) => state.style.navBarItemLabelHoverStyle, + ); + + const classBase = + 'items-center cursor-pointer dark:text-white dark:hover:text-slate-400'; + const classAddon = `${display} ${navBarItemLabelStyle} ${navBarItemLabelHoverStyle} ${ + useMargin ? 'my-2 mx-3' : 'py-2 px-3' + }`; + + return ( +
    + {children} +
    + ); +} diff --git a/frontend/src/components/NavBarMenuList.tsx b/frontend/src/components/NavBarMenuList.tsx new file mode 100644 index 0000000..d85a7b9 --- /dev/null +++ b/frontend/src/components/NavBarMenuList.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { MenuNavBarItem } from '../interfaces'; +import NavBarItem from './NavBarItem'; + +type Props = { + menu: MenuNavBarItem[]; +}; + +export default function NavBarMenuList({ menu }: Props) { + return ( + <> + {menu.map((item, index) => ( + + ))} + + ); +} diff --git a/frontend/src/components/NotificationBar.tsx b/frontend/src/components/NotificationBar.tsx new file mode 100644 index 0000000..e91f880 --- /dev/null +++ b/frontend/src/components/NotificationBar.tsx @@ -0,0 +1,65 @@ +import { mdiClose } from '@mdi/js'; +import React, { ReactNode, useState } from 'react'; +import { ColorKey } from '../interfaces'; +import { colorsBgLight, colorsOutline } from '../colors'; +import BaseButton from './BaseButton'; +import BaseIcon from './BaseIcon'; + +type Props = { + color: ColorKey; + icon?: string; + outline?: boolean; + children: ReactNode; + button?: ReactNode; +}; + +const NotificationBar = ({ outline = false, children, ...props }: Props) => { + const componentColorClass = outline + ? colorsOutline[props.color] + : colorsBgLight[props.color]; + + const [isDismissed, setIsDismissed] = useState(false); + + const dismiss = (e: React.MouseEvent) => { + e.preventDefault(); + + setIsDismissed(true); + }; + + if (isDismissed) { + return null; + } + + return ( +
    +
    +
    + {props.icon && ( + + )} + {children} +
    + {props.button} + {!props.button && ( + + )} +
    +
    + ); +}; + +export default NotificationBar; diff --git a/frontend/src/components/OverlayLayer.tsx b/frontend/src/components/OverlayLayer.tsx new file mode 100644 index 0000000..53c681d --- /dev/null +++ b/frontend/src/components/OverlayLayer.tsx @@ -0,0 +1,41 @@ +import React, { ReactNode } from 'react'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + zIndex?: string; + type?: string; + children?: ReactNode; + className?: string; + onClick: (e: React.MouseEvent) => void; +}; + +export default function OverlayLayer({ + zIndex = 'z-50', + type = 'flex', + children, + className, + ...props +}: Props) { + const overlayStyle = useAppSelector((state) => state.style.overlayStyle); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + + if (props.onClick) { + props.onClick(e); + } + }; + + return ( +
    +
    + + {children} +
    + ); +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..8203969 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { + mdiChevronDoubleLeft, + mdiChevronDoubleRight, + mdiChevronLeft, + mdiChevronRight, +} from '@mdi/js'; +import BaseIcon from './BaseIcon'; + +type Props = { + currentPage: number; + numPages: number; + setCurrentPage: any; +}; + +export const Pagination = ({ + currentPage, + numPages, + setCurrentPage, +}: Props) => { + return ( +
    + {currentPage === 0 && ( +
    + + +
    + )} + {currentPage !== 0 && ( +
    +
    setCurrentPage(0)}> + +
    +
    setCurrentPage(currentPage - 1)}> + +
    +
    + )} +

    + Page {currentPage + 1} of {numPages} +

    + {currentPage !== numPages - 1 && ( +
    +
    setCurrentPage(currentPage + 1)}> + +
    + +
    setCurrentPage(numPages - 1)}> + +
    +
    + )} + {currentPage === numPages - 1 && ( +
    + + +
    + )} +
    + ); +}; diff --git a/frontend/src/components/PasswordSetOrReset.tsx b/frontend/src/components/PasswordSetOrReset.tsx new file mode 100644 index 0000000..519c894 --- /dev/null +++ b/frontend/src/components/PasswordSetOrReset.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { toast, ToastContainer } from 'react-toastify'; + +import Head from 'next/head'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseButtons from '../components/BaseButtons'; +import BaseButton from '../components/BaseButton'; +import { passwordReset } from '../stores/authSlice'; +import { useAppDispatch } from '../stores/hooks'; + +export default function PasswordSetOrReset() { + const [loading, setLoading] = React.useState(false); + const [isInvitation, setIsInvitation] = React.useState(false); + const router = useRouter(); + const { token, invitation } = router.query; + + const notify = (type, msg) => toast(msg, { type }); + + const dispatch = useAppDispatch(); + + React.useEffect(() => { + if (invitation) { + setIsInvitation(true); + } + }, [invitation]); + + const handleSubmit = async (value) => { + setLoading(true); + if (typeof token === 'string') { + await dispatch( + passwordReset({ + token, + password: value.password, + type: isInvitation && 'invitation', + }), + ); + await router.push('/login'); + } + + setLoading(false); + }; + + return ( + <> + + {isInvitation && {getPageTitle('Set Password')}} + {!isInvitation && {getPageTitle('Reset Password')}} + + + +
    + + {isInvitation &&

    Set Password

    } + {!isInvitation &&

    Reset Password

    } +

    Enter your new password

    + + handleSubmit(values)} + > + {({ errors, touched }) => ( +
    + + + + + + + + + + +
    + )} +
    +
    +
    +
    + + + ); +} diff --git a/frontend/src/components/Permissions/CardPermissions.tsx b/frontend/src/components/Permissions/CardPermissions.tsx new file mode 100644 index 0000000..1948252 --- /dev/null +++ b/frontend/src/components/Permissions/CardPermissions.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + permissions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardPermissions = ({ + permissions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PERMISSIONS'); + + return ( +
    + {loading && } +
      + {!loading && + permissions.map((item, index) => ( +
    • +
      + + {item.name} + + +
      + +
      +
      +
      +
      +
      Name
      +
      +
      {item.name}
      +
      +
      +
      +
    • + ))} + {!loading && permissions.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardPermissions; diff --git a/frontend/src/components/Permissions/ListPermissions.tsx b/frontend/src/components/Permissions/ListPermissions.tsx new file mode 100644 index 0000000..7fcdcc1 --- /dev/null +++ b/frontend/src/components/Permissions/ListPermissions.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + permissions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListPermissions = ({ + permissions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PERMISSIONS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + permissions.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Name

    +

    {item.name}

    +
    + + +
    +
    + ))} + {!loading && permissions.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListPermissions; diff --git a/frontend/src/components/Permissions/TablePermissions.tsx b/frontend/src/components/Permissions/TablePermissions.tsx new file mode 100644 index 0000000..7153517 --- /dev/null +++ b/frontend/src/components/Permissions/TablePermissions.tsx @@ -0,0 +1,487 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configurePermissionsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSamplePermissions = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + permissions, + loading, + count, + notify: permissionsNotify, + refetch, + } = useAppSelector((state) => state.permissions); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (permissionsNotify.showNotification) { + notify( + permissionsNotify.typeNotification, + permissionsNotify.textNotification, + ); + } + }, [permissionsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `permissions`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={permissions ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSamplePermissions; diff --git a/frontend/src/components/Permissions/configurePermissionsCols.tsx b/frontend/src/components/Permissions/configurePermissionsCols.tsx new file mode 100644 index 0000000..f4af556 --- /dev/null +++ b/frontend/src/components/Permissions/configurePermissionsCols.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_PERMISSIONS'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Resources/CardResources.tsx b/frontend/src/components/Resources/CardResources.tsx new file mode 100644 index 0000000..5b4154c --- /dev/null +++ b/frontend/src/components/Resources/CardResources.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + resources: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardResources = ({ + resources, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_RESOURCES'); + + return ( +
    + {loading && } +
      + {!loading && + resources.map((item, index) => ( +
    • +
      + + {item.title} + + +
      + +
      +
      +
      +
      +
      Title
      +
      +
      {item.title}
      +
      +
      + +
      +
      + Content +
      +
      +
      + {item.content} +
      +
      +
      + +
      +
      Type
      +
      +
      {item.type}
      +
      +
      +
      +
    • + ))} + {!loading && resources.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardResources; diff --git a/frontend/src/components/Resources/ListResources.tsx b/frontend/src/components/Resources/ListResources.tsx new file mode 100644 index 0000000..c96dead --- /dev/null +++ b/frontend/src/components/Resources/ListResources.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + resources: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListResources = ({ + resources, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_RESOURCES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + resources.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Title

    +

    {item.title}

    +
    + +
    +

    Content

    +

    {item.content}

    +
    + +
    +

    Type

    +

    {item.type}

    +
    + + +
    +
    + ))} + {!loading && resources.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListResources; diff --git a/frontend/src/components/Resources/TableResources.tsx b/frontend/src/components/Resources/TableResources.tsx new file mode 100644 index 0000000..e1828f0 --- /dev/null +++ b/frontend/src/components/Resources/TableResources.tsx @@ -0,0 +1,524 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/resources/resourcesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureResourcesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import KanbanBoard from '../KanbanBoard/KanbanBoard'; +import axios from 'axios'; + +const perPage = 10; + +const TableSampleResources = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const [kanbanColumns, setKanbanColumns] = useState | null>(null); + const [kanbanFilters, setKanbanFilters] = useState(''); + + const { + resources, + loading, + count, + notify: resourcesNotify, + refetch, + } = useAppSelector((state) => state.resources); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (resourcesNotify.showNotification) { + notify( + resourcesNotify.typeNotification, + resourcesNotify.textNotification, + ); + } + }, [resourcesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + useEffect(() => { + setKanbanColumns([ + { id: 'meditation', label: 'meditation' }, + + { id: 'article', label: 'article' }, + + { id: 'video', label: 'video' }, + ]); + }, []); + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setKanbanFilters(''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + + setKanbanFilters(generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + + setKanbanFilters(''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `resources`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={resources ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {!showGrid && kanbanColumns && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleResources; diff --git a/frontend/src/components/Resources/configureResourcesCols.tsx b/frontend/src/components/Resources/configureResourcesCols.tsx new file mode 100644 index 0000000..2e19fe0 --- /dev/null +++ b/frontend/src/components/Resources/configureResourcesCols.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_RESOURCES'); + + return [ + { + field: 'title', + headerName: 'Title', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'content', + headerName: 'Content', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'type', + headerName: 'Type', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/RichTextField.tsx b/frontend/src/components/RichTextField.tsx new file mode 100644 index 0000000..29b43a1 --- /dev/null +++ b/frontend/src/components/RichTextField.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useId, useState } from 'react'; +import { Editor } from '@tinymce/tinymce-react'; +import { tinyKey } from '../config'; +import { useAppSelector } from '../stores/hooks'; + +export const RichTextField = ({ options, field, form, itemRef, showField }) => { + const [value, setValue] = useState(null); + const darkMode = useAppSelector((state) => state.style.darkMode); + + useEffect(() => { + if (field.value) { + setValue(field.value); + } + }, [field.value]); + + const handleChange = (value) => { + form.setFieldValue(field.name, value); + setValue(value); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/Roles/CardRoles.tsx b/frontend/src/components/Roles/CardRoles.tsx new file mode 100644 index 0000000..4daa835 --- /dev/null +++ b/frontend/src/components/Roles/CardRoles.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + roles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardRoles = ({ + roles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ROLES'); + + return ( +
    + {loading && } +
      + {!loading && + roles.map((item, index) => ( +
    • +
      + + {item.name} + + +
      + +
      +
      +
      +
      +
      Name
      +
      +
      {item.name}
      +
      +
      + +
      +
      + Permissions +
      +
      +
      + {dataFormatter + .permissionsManyListFormatter(item.permissions) + .join(', ')} +
      +
      +
      +
      +
    • + ))} + {!loading && roles.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardRoles; diff --git a/frontend/src/components/Roles/ListRoles.tsx b/frontend/src/components/Roles/ListRoles.tsx new file mode 100644 index 0000000..a6081ff --- /dev/null +++ b/frontend/src/components/Roles/ListRoles.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + roles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListRoles = ({ + roles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ROLES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + roles.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Name

    +

    {item.name}

    +
    + +
    +

    Permissions

    +

    + {dataFormatter + .permissionsManyListFormatter(item.permissions) + .join(', ')} +

    +
    + + +
    +
    + ))} + {!loading && roles.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListRoles; diff --git a/frontend/src/components/Roles/TableRoles.tsx b/frontend/src/components/Roles/TableRoles.tsx new file mode 100644 index 0000000..5947a5c --- /dev/null +++ b/frontend/src/components/Roles/TableRoles.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/roles/rolesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureRolesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleRoles = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + roles, + loading, + count, + notify: rolesNotify, + refetch, + } = useAppSelector((state) => state.roles); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (rolesNotify.showNotification) { + notify(rolesNotify.typeNotification, rolesNotify.textNotification); + } + }, [rolesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `roles`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={roles ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleRoles; diff --git a/frontend/src/components/Roles/configureRolesCols.tsx b/frontend/src/components/Roles/configureRolesCols.tsx new file mode 100644 index 0000000..8846f0c --- /dev/null +++ b/frontend/src/components/Roles/configureRolesCols.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_ROLES'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'permissions', + headerName: 'Permissions', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + dataFormatter.permissionsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx new file mode 100644 index 0000000..4f6de00 --- /dev/null +++ b/frontend/src/components/Search.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { useAppSelector } from '../stores/hooks'; + +const Search = () => { + const router = useRouter(); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const validateSearch = (value) => { + let error; + if (!value) { + error = 'Required'; + } else if (value.length < 2) { + error = 'Minimum length: 2 characters'; + } + return error; + }; + return ( + { + router.push(`/search?query=${values.search}`); + resetForm(); + setSubmitting(false); + }} + validateOnBlur={false} + validateOnChange={false} + > + {({ errors, touched, values }) => ( +
    + + {errors.search && touched.search && values.search.length < 2 ? ( +
    + {errors.search} +
    + ) : null} + + )} +
    + ); +}; +export default Search; diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx new file mode 100644 index 0000000..4a417ca --- /dev/null +++ b/frontend/src/components/SearchResults.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import CardBox from './CardBox'; +import { useRouter } from 'next/router'; +import { humanize } from '../helpers/humanize'; + +const SearchResults = ({ searchResults, searchQuery }) => { + const router = useRouter(); + + return ( + <> +

    Matches with: {searchQuery}

    + {Object.keys(searchResults).map((tableName) => ( + <> +

    {humanize(tableName)}

    + +
    + + + + {searchResults[tableName].length > 0 && + Object.keys(searchResults[tableName][0]).map((key) => { + if ( + key !== 'tableName' && + key !== 'id' && + key !== 'matchAttribute' + ) { + return ( + + ); + } + return null; + })} + + + + {searchResults[tableName].map((item, index) => ( + + {Object.keys(item).map((key) => { + if ( + key !== 'tableName' && + key !== 'id' && + key !== 'matchAttribute' + ) { + return ( + + ); + } + return null; + })} + + ))} + +
    + {humanize(key)} +
    + router.push( + `/${tableName}/${tableName}-view/?id=${item['id']}`, + ) + } + > + {item[key]} +
    +
    + {!Object.keys(searchResults).length && ( +
    No data
    + )} +
    + + ))} + {!Object.keys(searchResults).length && ( +
    No matches
    + )} + + ); +}; + +export default SearchResults; diff --git a/frontend/src/components/SectionFullScreen.tsx b/frontend/src/components/SectionFullScreen.tsx new file mode 100644 index 0000000..96f8ef3 --- /dev/null +++ b/frontend/src/components/SectionFullScreen.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode } from 'react'; +import { BgKey } from '../interfaces'; +import { + gradientBgPurplePink, + gradientBgDark, + gradientBgPinkRed, + gradientBgViolet, +} from '../colors'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + bg: BgKey; + children: ReactNode; +}; + +export default function SectionFullScreen({ bg, children }: Props) { + const darkMode = useAppSelector((state) => state.style.darkMode); + + let componentClass = 'flex min-h-screen items-center justify-center '; + + if (darkMode) { + componentClass += gradientBgDark; + } else if (bg === 'violet') { + componentClass += gradientBgViolet; + } else if (bg === 'purplePink') { + componentClass += gradientBgPurplePink; + } else if (bg === 'pinkRed') { + componentClass += gradientBgPinkRed; + } + + return
    {children}
    ; +} diff --git a/frontend/src/components/SectionMain.tsx b/frontend/src/components/SectionMain.tsx new file mode 100644 index 0000000..ba57321 --- /dev/null +++ b/frontend/src/components/SectionMain.tsx @@ -0,0 +1,10 @@ +import React, { ReactNode } from 'react'; +import { containerMaxW } from '../config'; + +type Props = { + children: ReactNode; +}; + +export default function SectionMain({ children }: Props) { + return
    {children}
    ; +} diff --git a/frontend/src/components/SectionTitle.tsx b/frontend/src/components/SectionTitle.tsx new file mode 100644 index 0000000..c07d85b --- /dev/null +++ b/frontend/src/components/SectionTitle.tsx @@ -0,0 +1,38 @@ +import React, { ReactNode } from 'react'; + +type Props = { + custom?: boolean; + first?: boolean; + last?: boolean; + children: ReactNode; +}; + +const SectionTitle = ({ + custom = false, + first = false, + last = false, + children, +}: Props) => { + let classAddon = '-my-6'; + + if (first) { + classAddon = '-mb-6'; + } else if (last) { + classAddon = '-mt-6'; + } + + return ( +
    + {custom && children} + {!custom && ( +

    + {children} +

    + )} +
    + ); +}; + +export default SectionTitle; diff --git a/frontend/src/components/SectionTitleLineWithButton.tsx b/frontend/src/components/SectionTitleLineWithButton.tsx new file mode 100644 index 0000000..8cfd157 --- /dev/null +++ b/frontend/src/components/SectionTitleLineWithButton.tsx @@ -0,0 +1,40 @@ +import { mdiCog } from '@mdi/js'; +import React, { Children, ReactNode } from 'react'; +import BaseButton from './BaseButton'; +import BaseIcon from './BaseIcon'; +import IconRounded from './IconRounded'; +import { humanize } from '../helpers/humanize'; + +type Props = { + icon: string; + title: string; + main?: boolean; + children?: ReactNode; +}; + +export default function SectionTitleLineWithButton({ + icon, + title, + main = false, + children, +}: Props) { + const hasChildren = !!Children.count(children); + + return ( +
    +
    + {icon && main && ( + + )} + {icon && !main && } +

    + {humanize(title)} +

    +
    + {children} + {!hasChildren && } +
    + ); +} diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx new file mode 100644 index 0000000..56fb8fc --- /dev/null +++ b/frontend/src/components/SelectField.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useId, useState } from 'react'; +import { AsyncPaginate } from 'react-select-async-paginate'; +import axios from 'axios'; + +export const SelectField = ({ + options, + field, + form, + itemRef, + showField, + disabled, +}) => { + const [value, setValue] = useState(null); + const PAGE_SIZE = 100; + + useEffect(() => { + if (options?.id && field?.value?.id) { + setValue({ value: field.value?.id, label: field.value[showField] }); + form.setFieldValue(field.name, field.value?.id); + } else if (!field.value) { + setValue(null); + } + }, [options?.id, field?.value?.id, field?.value]); + + const mapResponseToValuesAndLabels = (data) => ({ + value: data.id, + label: data.label, + }); + const handleChange = (option) => { + form.setFieldValue(field.name, option?.value || null); + setValue(option); + }; + + async function callApi(inputValue: string, loadedOptions: any[]) { + const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${ + loadedOptions.length + }${inputValue ? `&query=${inputValue}` : ''}`; + const { data } = await axios(path); + return { + options: data.map(mapResponseToValuesAndLabels), + hasMore: data.length === PAGE_SIZE, + }; + } + return ( + 'px-1 py-2', + }} + classNamePrefix='react-select' + instanceId={useId()} + value={value} + debounceTimeout={1000} + loadOptions={callApi} + onChange={handleChange} + defaultOptions + isDisabled={disabled} + isClearable + /> + ); +}; diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx new file mode 100644 index 0000000..c496ac6 --- /dev/null +++ b/frontend/src/components/SelectFieldMany.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useId, useState } from 'react'; +import { AsyncPaginate } from 'react-select-async-paginate'; +import axios from 'axios'; + +export const SelectFieldMany = ({ + options, + field, + form, + itemRef, + showField, +}) => { + const [value, setValue] = useState([]); + const PAGE_SIZE = 100; + + useEffect(() => { + if (field.value?.[0] && typeof field.value[0] !== 'string') { + form.setFieldValue( + field.name, + field.value.map((el) => el.id), + ); + } else if (!field.value || field.value.length === 0) { + setValue([]); + } + }, [field.name, field.value, form]); + + useEffect(() => { + if (options) { + setValue(options.map((el) => ({ value: el.id, label: el[showField] }))); + form.setFieldValue( + field.name, + options.map((el) => ({ value: el.id, label: el[showField] })), + ); + } + }, [options]); + + const mapResponseToValuesAndLabels = (data) => ({ + value: data.id, + label: data.label, + }); + + const handleChange = (data: any) => { + setValue(data); + form.setFieldValue( + field.name, + data.map((el) => el?.value || null), + ); + }; + + async function callApi(inputValue: string, loadedOptions: any[]) { + const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${ + loadedOptions.length + }${inputValue ? `&query=${inputValue}` : ''}`; + const { data } = await axios(path); + return { + options: data.map(mapResponseToValuesAndLabels), + hasMore: data.length === PAGE_SIZE, + }; + } + return ( + 'px-1 py-2', + }} + classNamePrefix='react-select' + instanceId={useId()} + value={value} + isMulti + debounceTimeout={1000} + loadOptions={callApi} + onChange={handleChange} + defaultOptions + isClearable + /> + ); +}; diff --git a/frontend/src/components/Sessions/CardSessions.tsx b/frontend/src/components/Sessions/CardSessions.tsx new file mode 100644 index 0000000..99bf31b --- /dev/null +++ b/frontend/src/components/Sessions/CardSessions.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + sessions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardSessions = ({ + sessions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SESSIONS'); + + return ( +
    + {loading && } +
      + {!loading && + sessions.map((item, index) => ( +
    • +
      + + {item.start_time} + + +
      + +
      +
      +
      +
      +
      + Therapist +
      +
      +
      + {dataFormatter.therapistsOneListFormatter(item.therapist)} +
      +
      +
      + +
      +
      + Client +
      +
      +
      + {dataFormatter.clientsOneListFormatter(item.client)} +
      +
      +
      + +
      +
      + StartTime +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.start_time)} +
      +
      +
      + +
      +
      + EndTime +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.end_time)} +
      +
      +
      + +
      +
      Notes
      +
      +
      {item.notes}
      +
      +
      +
      +
    • + ))} + {!loading && sessions.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardSessions; diff --git a/frontend/src/components/Sessions/ListSessions.tsx b/frontend/src/components/Sessions/ListSessions.tsx new file mode 100644 index 0000000..fc48964 --- /dev/null +++ b/frontend/src/components/Sessions/ListSessions.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + sessions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListSessions = ({ + sessions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SESSIONS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + sessions.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Therapist

    +

    + {dataFormatter.therapistsOneListFormatter(item.therapist)} +

    +
    + +
    +

    Client

    +

    + {dataFormatter.clientsOneListFormatter(item.client)} +

    +
    + +
    +

    StartTime

    +

    + {dataFormatter.dateTimeFormatter(item.start_time)} +

    +
    + +
    +

    EndTime

    +

    + {dataFormatter.dateTimeFormatter(item.end_time)} +

    +
    + +
    +

    Notes

    +

    {item.notes}

    +
    + + +
    +
    + ))} + {!loading && sessions.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListSessions; diff --git a/frontend/src/components/Sessions/TableSessions.tsx b/frontend/src/components/Sessions/TableSessions.tsx new file mode 100644 index 0000000..df6f9a0 --- /dev/null +++ b/frontend/src/components/Sessions/TableSessions.tsx @@ -0,0 +1,513 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/sessions/sessionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureSessionsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import BigCalendar from '../BigCalendar'; +import { SlotInfo } from 'react-big-calendar'; + +const perPage = 100; + +const TableSampleSessions = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + sessions, + loading, + count, + notify: sessionsNotify, + refetch, + } = useAppSelector((state) => state.sessions); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (sessionsNotify.showNotification) { + notify(sessionsNotify.typeNotification, sessionsNotify.textNotification); + } + }, [sessionsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleCreateEventAction = ({ start, end }: SlotInfo) => { + router.push( + `/sessions/sessions-new?dateRangeStart=${start.toISOString()}&dateRangeEnd=${end.toISOString()}`, + ); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `sessions`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={sessions ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {!showGrid && ( + { + loadData( + 0, + `&calendarStart=${range.start}&calendarEnd=${range.end}`, + ); + }} + entityName={'sessions'} + /> + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleSessions; diff --git a/frontend/src/components/Sessions/configureSessionsCols.tsx b/frontend/src/components/Sessions/configureSessionsCols.tsx new file mode 100644 index 0000000..851f673 --- /dev/null +++ b/frontend/src/components/Sessions/configureSessionsCols.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_SESSIONS'); + + return [ + { + field: 'therapist', + headerName: 'Therapist', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('therapists'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'client', + headerName: 'Client', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('clients'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'start_time', + headerName: 'StartTime', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.start_time), + }, + + { + field: 'end_time', + headerName: 'EndTime', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.end_time), + }, + + { + field: 'notes', + headerName: 'Notes', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/SmartWidget/SmartWidget.tsx b/frontend/src/components/SmartWidget/SmartWidget.tsx new file mode 100644 index 0000000..ef76c98 --- /dev/null +++ b/frontend/src/components/SmartWidget/SmartWidget.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import BaseButton from '../BaseButton'; +import BaseIcon from '../BaseIcon'; +import * as icons from '@mdi/js'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; + +import { fetchWidgets, removeWidget } from '../../stores/roles/rolesSlice'; +import { WidgetChartType, WidgetType } from './models/widget.model'; +import { BarChart } from './components/BarChart'; +import { PieChart } from './components/PieChart'; +import { AreaChart } from './components/AreaChart'; +import { LineChart } from './components/LineChart'; + +export const SmartWidget = ({ widget, userId, admin, roleId }) => { + const dispatch = useAppDispatch(); + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + + const deleteWidget = async () => { + await dispatch( + removeWidget({ id: userId, widgetId: widget.widget_id, roleId }), + ); + await dispatch(fetchWidgets(roleId)); + }; + + return ( +
    +
    +
    +
    + {widget.label} +
    + + {admin && ( + + )} +
    + +
    +
    + {widget.value ? ( + widget.widget_type === WidgetType.chart ? ( + widget.chart_type === WidgetChartType.bar ? ( + + ) : widget.chart_type === WidgetChartType.line ? ( + + ) : widget.chart_type === WidgetChartType.pie ? ( + + ) : widget.chart_type === WidgetChartType.area ? ( + + ) : widget.chart_type === WidgetChartType.funnel ? ( + + ) : null + ) : ( +
    + {widget.value} +
    + ) + ) : ( +
    + Something went wrong, please try again or use a different query. +
    + )} +
    + + {widget.type === WidgetType.scalar && widget.mdiIcon && ( +
    + +
    + )} +
    +
    +
    + ); +}; diff --git a/frontend/src/components/SmartWidget/components/AreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart.tsx new file mode 100644 index 0000000..50bd5c7 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/AreaChart.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { WidgetLibName } from '../models/widget.model'; +import { ApexAreaChart } from './AreaChart/ApexAreaChart'; +import { ChartJSAreaChart } from './AreaChart/ChartJSAreaChart'; + +export const AreaChart = ({ widget }) => { + return ( + <> + {!widget.lib_name && } + {widget.lib_name === WidgetLibName.chartjs && ( + + )} + {widget.lib_name === WidgetLibName.apex && ( + + )} + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx new file mode 100644 index 0000000..656008a --- /dev/null +++ b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import { humanize } from '../../../../helpers/humanize'; + +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); +type ValueType = { [key: string]: string | number }[]; + +export const ApexAreaChart = ({ widget }) => { + const dataForLineChart = (value: any[]) => { + if (!value?.length || value?.length > 10000) + return [{ name: '', data: [] }]; + + const valueKey = Object.keys(value[0])[1]; + const data = value.map((el) => +el[valueKey]); + + return [{ name: humanize(valueKey), data }]; + }; + + const optionsForLineChart = ( + value: ValueType, + chartColor: string[], + currency: boolean, + ) => { + const chartColors = Array.isArray(chartColor) + ? chartColor + : [chartColor || '#3751FF']; + const defaultOptions = { + xaxis: {}, + chart: { + toolbar: { + show: true, + offsetX: 0, + offsetY: 0, + tools: { + download: true, + selection: true, + zoom: true, + zoomin: true, + zoomout: true, + pan: true, + }, + export: { + csv: { + filename: undefined, + columnDelimiter: ',', + headerCategory: 'category', + headerValue: 'value', + }, + svg: { + filename: undefined, + }, + png: { + filename: undefined, + }, + }, + }, + }, + plotOptions: { + bar: { + distributed: false, + }, + }, + colors: [], + }; + + if (!value?.length || value?.length > 10000) return defaultOptions; + + const key = Object.keys(value[0])[0]; + const categories = value + .map((el) => el[key]) + .map((item) => + typeof item === 'string' && item?.length > 7 + ? item?.slice(0, 7) + : item || '', + ); + + if (categories.length <= 3) { + defaultOptions.plotOptions = { + bar: { + distributed: true, + }, + }; + } + + const colors = []; + for (let i = 0; i < categories.length; i++) { + colors.push(chartColors[i % chartColors.length]); + } + + return { + ...defaultOptions, + yaxis: { + labels: { + formatter: function (value) { + if (currency) { + return '$' + value; + } else { + return value; + } + }, + }, + }, + dataLabels: { + formatter: (val) => { + if (currency) { + return '$' + val; + } else { + return val; + } + }, + }, + legend: { + show: false, + }, + xaxis: { + categories, + }, + colors, + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx new file mode 100644 index 0000000..5bb4cd2 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import chroma from 'chroma-js'; +import { humanize } from '../../../../helpers/humanize'; +import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Filler, + Legend, + ChartData, +} from 'chart.js'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Filler, + Legend, +); + +export const ChartJSAreaChart = ({ widget }) => { + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + display: true, + }, + x: { + display: true, + }, + }, + plugins: { + legend: { + display: true, + }, + }, + }; + + const dataForBarChart = ( + value: any[], + chartColors: string[], + ): ChartData<'line', number[], string> => { + if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; + const initColors = Array.isArray(chartColors) + ? chartColors + : [chartColors || '#3751FF']; + + const valueKey = findFirstNumericKey(value[0]); + const label = humanize(valueKey); + const data = value.map((el) => +el[valueKey]); + const labels = value.map((el) => + Object.keys(el).length <= 2 + ? humanize(String(el[Object.keys(el)[0]])) + : collectOtherData(el, valueKey), + ); + + const backgroundColor = + labels.length > initColors.length + ? chroma + .scale([ + chroma(initColors[0]).brighten(), + chroma(initColors.slice(-1)[0]).darken(), + ]) + .colors(labels.length) + : initColors; + + return { + labels, + datasets: [ + { + label, + data, + backgroundColor, + }, + ], + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/BarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart.tsx new file mode 100644 index 0000000..bf7a79b --- /dev/null +++ b/frontend/src/components/SmartWidget/components/BarChart.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { ChartJSBarChart } from './BarChart/ChartJSBarChart'; +import { ApexBarChart } from './BarChart/ApexBarChart'; +import { WidgetLibName } from '../models/widget.model'; + +export const BarChart = ({ widget }) => { + return ( + <> + {!widget.lib_name && } + {widget.lib_name === WidgetLibName.chartjs && ( + + )} + {widget.lib_name === WidgetLibName.apex && ( + + )} + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx new file mode 100644 index 0000000..5b3d3e7 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import { humanize } from '../../../../helpers/humanize'; + +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); +type ValueType = { [key: string]: string | number }[]; + +export const ApexBarChart = ({ widget }) => { + const dataForBarChart = (value: any[]) => { + if (!value?.length || value?.length > 10000) + return [{ name: '', data: [] }]; + + const valueKey = Object.keys(value[0])[1]; + const data = value.map((el) => +el[valueKey]); + + return [{ name: humanize(valueKey), data }]; + }; + const optionsForBarChart = ( + value: ValueType, + chartColor: string[], + currency: boolean, + ) => { + const chartColors = Array.isArray(chartColor) + ? chartColor + : [chartColor || '#3751FF']; + const defaultOptions = { + xaxis: {}, + chart: { + toolbar: { + show: true, + offsetX: 0, + offsetY: 0, + tools: { + download: true, + selection: true, + zoom: true, + }, + export: { + csv: { + filename: undefined, + columnDelimiter: ',', + headerCategory: 'category', + headerValue: 'value', + }, + svg: { + filename: undefined, + }, + png: { + filename: undefined, + }, + }, + }, + }, + plotOptions: { + bar: { + distributed: false, + }, + }, + colors: [], + }; + + if (!value?.length || value?.length > 10000) return defaultOptions; + + const key = Object.keys(value[0])[0]; + const categories = value + .map((el) => el[key]) + .map((item) => + typeof item === 'string' && item?.length > 20 + ? item?.slice(0, 15) + : item || '', + ); + + if (categories.length <= 3) { + defaultOptions.plotOptions = { + bar: { + distributed: true, + }, + }; + } + + const colors = []; + for (let i = 0; i < categories.length; i++) { + colors.push(chartColors[i % chartColors.length]); + } + + return { + ...defaultOptions, + yaxis: { + labels: { + formatter: function (value) { + if (currency) { + return '$' + value; + } else { + return value; + } + }, + }, + }, + dataLabels: { + formatter: (val) => { + if (currency) { + return '$' + val; + } else { + return val; + } + }, + }, + legend: { + show: true, + }, + xaxis: { + categories, + }, + colors, + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx new file mode 100644 index 0000000..eac4769 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { humanize } from '../../../../helpers/humanize'; +import { Bar } from 'react-chartjs-2'; +import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + ChartData, + Legend, + LinearScale, + Title, + Tooltip, +} from 'chart.js'; +import chroma from 'chroma-js'; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +); + +export const ChartJSBarChart = ({ widget }) => { + console.log(widget); + const options = () => { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top' as const, + }, + title: { + display: true, + text: widget.label, + }, + }, + }; + }; + + const dataForBarChart = ( + value: any[], + chartColors: string[], + ): ChartData<'bar', number[], string> => { + if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; + + const initColors = Array.isArray(chartColors) + ? chartColors + : [chartColors || '#3751FF']; + + const valueKey = findFirstNumericKey(value[0]); + const label = humanize(valueKey); + const data = value.map((el) => +el[valueKey]); + const labels = value.map((el) => + Object.keys(el).length <= 2 + ? humanize(String(el[Object.keys(el)[0]])) + : collectOtherData(el, valueKey), + ); + + const backgroundColor = + labels.length > initColors.length + ? chroma + .scale([ + chroma(initColors[0]).brighten(), + chroma(initColors.slice(-1)[0]).darken(), + ]) + .colors(labels.length) + : initColors; + + return { + labels, + datasets: [ + { + label, + data, + backgroundColor, + }, + ], + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/FunnelChart.tsx b/frontend/src/components/SmartWidget/components/FunnelChart.tsx new file mode 100644 index 0000000..3d91280 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/FunnelChart.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import { humanize } from '../../../helpers/humanize'; + +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); +type ValueType = { [key: string]: string | number }[]; + +export const FunnelChart = ({ widget }) => { + const dataForBarChart = (value: any[]) => { + if (!value?.length || value?.length > 10000) + return [{ name: '', data: [] }]; + const valueKey = Object.keys(value[0])[1]; + const data = value.map((el) => +el[valueKey]); + + return [{ name: humanize(valueKey), data }]; + }; + const optionsForBarChart = ( + value: ValueType, + chartColor: string[], + currency: boolean, + ) => { + const chartColors = Array.isArray(chartColor) + ? chartColor + : [chartColor || '#3751FF']; + const defaultOptions = { + xaxis: {}, + chart: { + toolbar: { + show: true, + offsetX: 0, + offsetY: 0, + tools: { + download: true, + selection: true, + zoom: true, + }, + export: { + csv: { + filename: undefined, + columnDelimiter: ',', + headerCategory: 'category', + headerValue: 'value', + }, + svg: { + filename: undefined, + }, + png: { + filename: undefined, + }, + }, + }, + }, + plotOptions: { + bar: { + distributed: false, + horizontal: true, + isFunnel: true, + }, + }, + colors: [], + }; + + if (!value?.length || value?.length > 10000) return defaultOptions; + + const key = Object.keys(value[0])[0]; + const categories = value + .map((el) => el[key]) + .map((item) => + typeof item === 'string' && item?.length > 20 + ? item?.slice(0, 15) + : item || '', + ); + + if (categories.length <= 3) { + defaultOptions.plotOptions = { + bar: { + distributed: true, + horizontal: true, + isFunnel: true, + }, + }; + } + + const colors = []; + for (let i = 0; i < categories.length; i++) { + colors.push(chartColors[i % chartColors.length]); + } + + return { + ...defaultOptions, + yaxis: { + labels: { + formatter: function (value) { + if (currency) { + return '$' + value; + } else { + return value; + } + }, + }, + }, + dataLabels: { + formatter: (val) => { + if (currency) { + return '$' + val; + } else { + return val; + } + }, + }, + legend: { + show: true, + }, + xaxis: { + categories, + }, + colors, + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/LineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart.tsx new file mode 100644 index 0000000..3b9e7b0 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/LineChart.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { WidgetLibName } from '../models/widget.model'; +import { ApexLineChart } from './LineChart/ApexLineChart'; +import { ChartJSLineChart } from './LineChart/ChartJSLineChart'; + +export const LineChart = ({ widget }) => { + return ( + <> + {!widget.lib_name && } + {widget.lib_name === WidgetLibName.chartjs && ( + + )} + {widget.lib_name === WidgetLibName.apex && ( + + )} + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx new file mode 100644 index 0000000..aa77a76 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import { humanize } from '../../../../helpers/humanize'; + +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); +type ValueType = { [key: string]: string | number }[]; + +export const ApexLineChart = ({ widget }) => { + const dataForLineChart = (value: any[]) => { + if (!value?.length || value?.length > 10000) + return [{ name: '', data: [] }]; + + const valueKey = Object.keys(value[0])[1]; + const data = value.map((el) => +el[valueKey]); + + return [{ name: humanize(valueKey), data }]; + }; + + const optionsForLineChart = ( + value: ValueType, + chartColor: string[], + currency: boolean, + ) => { + const chartColors = Array.isArray(chartColor) + ? chartColor + : [chartColor || '#3751FF']; + const defaultOptions = { + xaxis: {}, + chart: { + toolbar: { + show: true, + offsetX: 0, + offsetY: 0, + tools: { + download: true, + selection: true, + zoom: true, + zoomin: true, + zoomout: true, + pan: true, + }, + export: { + csv: { + filename: undefined, + columnDelimiter: ',', + headerCategory: 'category', + headerValue: 'value', + }, + svg: { + filename: undefined, + }, + png: { + filename: undefined, + }, + }, + }, + }, + plotOptions: { + bar: { + distributed: false, + }, + }, + colors: [], + }; + + if (!value?.length || value?.length > 10000) return defaultOptions; + + const key = Object.keys(value[0])[0]; + const categories = value + .map((el) => el[key]) + .map((item) => + typeof item === 'string' && item?.length > 7 + ? item?.slice(0, 7) + : item || '', + ); + + if (categories.length <= 3) { + defaultOptions.plotOptions = { + bar: { + distributed: true, + }, + }; + } + + const colors = []; + for (let i = 0; i < categories.length; i++) { + colors.push(chartColors[i % chartColors.length]); + } + + return { + ...defaultOptions, + yaxis: { + labels: { + formatter: function (value) { + if (currency) { + return '$' + value; + } else { + return value; + } + }, + }, + }, + dataLabels: { + formatter: (val) => { + if (currency) { + return '$' + val; + } else { + return val; + } + }, + }, + legend: { + show: false, + }, + xaxis: { + categories, + }, + colors, + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx new file mode 100644 index 0000000..a2fe5ba --- /dev/null +++ b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { humanize } from '../../../../helpers/humanize'; +import { Line } from 'react-chartjs-2'; +import chroma from 'chroma-js'; +import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; +import { Widget } from '../../models/widget.model'; +import { + Chart, + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, + ChartData, +} from 'chart.js'; + +Chart.register( + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, +); + +interface Props { + widget: Widget; +} + +export const ChartJSLineChart = (props: Props) => { + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + display: true, + }, + x: { + display: true, + }, + }, + plugins: { + legend: { + display: true, + }, + }, + }; + + const dataForBarChart = ( + value: any[], + chartColors: string[], + ): ChartData<'line', number[], string> => { + if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; + const initColors = Array.isArray(chartColors) + ? chartColors + : [chartColors || '#3751FF']; + + const valueKey = findFirstNumericKey(value[0]); + const label = humanize(valueKey); + const data = value.map((el) => +el[valueKey]); + const labels = value.map((el) => + Object.keys(el).length <= 2 + ? humanize(String(el[Object.keys(el)[0]])) + : collectOtherData(el, valueKey), + ); + + const backgroundColor = + labels.length > initColors.length + ? chroma + .scale([ + chroma(initColors[0]).brighten(), + chroma(initColors.slice(-1)[0]).darken(), + ]) + .colors(labels.length) + : initColors; + + return { + labels, + datasets: [ + { + label, + data, + backgroundColor, + }, + ], + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/PieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart.tsx new file mode 100644 index 0000000..f6f0fd3 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/PieChart.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { WidgetLibName } from '../models/widget.model'; +import { ApexPieChart } from './PieChart/ApexPieChart'; +import { ChartJSPieChart } from './PieChart/ChartJSPieChart'; + +export const PieChart = ({ widget }) => { + return ( + <> + {!widget.lib_name && } + {widget.lib_name === WidgetLibName.chartjs && ( + + )} + {widget.lib_name === WidgetLibName.apex && ( + + )} + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx new file mode 100644 index 0000000..dfe5572 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import chroma from 'chroma-js'; + +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); +type ValueType = { [key: string]: string | number }[]; + +export const ApexPieChart = ({ widget }) => { + const optionsForPieChart = (value: ValueType, chartColor: string) => { + const chartColors = Array.isArray(chartColor) + ? chartColor + : [chartColor || '#3751FF']; + const defaultOptions = { + xaxis: {}, + toolbar: { + show: true, + offsetX: 0, + offsetY: 0, + tools: { + download: true, + selection: true, + zoom: true, + zoomin: true, + zoomout: true, + pan: true, + customIcons: [], + }, + export: { + csv: { + filename: undefined, + columnDelimiter: ',', + headerCategory: 'category', + headerValue: 'value', + }, + svg: { + filename: undefined, + }, + png: { + filename: undefined, + }, + }, + autoSelected: 'zoom', + }, + colors: [], + }; + + if (!value?.length || value?.length > 10000) return defaultOptions; + + if ( + !isNaN(Number(value[0][Object.keys(value[0])[1]])) && + isFinite(Number(value[0][Object.keys(value[0])[1]])) + ) { + const labels = value + .map((el) => String(el[Object.keys(value[0])[0]])) + .reverse(); + + let colors: string[] | (string & any[]); + if (labels.length > chartColors.length) { + colors = chroma + .scale([ + chroma(chartColors.at(0)).brighten(), + chroma(chartColors.at(-1)).darken(), + ]) + .colors(labels.length); + } else { + colors = chartColors; + } + + return { + ...defaultOptions, + colors, + labels, + }; + } + const key = Object.keys(value[0])[1]; + const categories = value.map((el) => String(el[key])).reverse(); + + return { + ...defaultOptions, + labels: categories, + }; + }; + const dataForPieChart = (value: any[]) => { + if (!value?.length || value?.length > 10000) + return [{ name: '', data: [] }]; + + if ( + !isNaN(parseFloat(value[0][Object.keys(value[0])[1]])) && + isFinite(value[0][Object.keys(value[0])[1]]) + ) { + return value.map((el) => +el[Object.keys(value[0])[1]]).reverse(); + } + const valueKey = Object.keys(value[0])[0]; + return value.map((el) => +el[valueKey]).reverse(); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx new file mode 100644 index 0000000..2a20155 --- /dev/null +++ b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { humanize } from '../../../../helpers/humanize'; +import { Pie } from 'react-chartjs-2'; +import chroma from 'chroma-js'; +import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; + +import { + Chart as ChartJS, + ArcElement, + Tooltip, + Legend, + ChartData, +} from 'chart.js'; + +ChartJS.register(ArcElement, Tooltip, Legend); + +export const ChartJSPieChart = ({ widget }) => { + const options = () => { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right' as const, + }, + title: { + display: true, + text: widget.label, + }, + }, + }; + }; + + const dataForBarChart = ( + value: any[], + chartColors: string[], + ): ChartData<'pie', number[], string> => { + if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; + const initColors = Array.isArray(chartColors) + ? chartColors + : [chartColors || '#3751FF']; + + const valueKey = findFirstNumericKey(value[0]); + const label = humanize(valueKey); + const data = value.map((el) => +el[valueKey]); + const labels = value.map((el) => + Object.keys(el).length <= 2 + ? humanize(String(el[Object.keys(el)[0]])) + : collectOtherData(el, valueKey), + ); + + const backgroundColor = + labels.length > initColors.length + ? chroma + .scale([ + chroma(initColors[0]).brighten(), + chroma(initColors.slice(-1)[0]).darken(), + ]) + .colors(labels.length) + : initColors; + + return { + labels, + datasets: [ + { + label, + data, + backgroundColor, + }, + ], + }; + }; + + return ( + + ); +}; diff --git a/frontend/src/components/SmartWidget/models/widget.model.ts b/frontend/src/components/SmartWidget/models/widget.model.ts new file mode 100644 index 0000000..362d65c --- /dev/null +++ b/frontend/src/components/SmartWidget/models/widget.model.ts @@ -0,0 +1,35 @@ +export enum WidgetLibName { + apex = 'apex', + chartjs = 'chartjs', +} + +export enum WidgetChartType { + scalar = 'scalar', + bar = 'bar', + line = 'line', + pie = 'pie', + area = 'area', + funnel = 'funnel', +} + +export enum WidgetType { + chart = 'chart', + scalar = 'scalar', +} + +export interface Widget { + type: WidgetType; + chartType: WidgetChartType; + query: string; + mdiIcon: string; + iconColor: string; + label: string; + id: string; + lib?: WidgetLibName; + value: any[]; + chartColors: string[]; + options?: any; + prompt: string; + color: string; + color_array: string[]; +} diff --git a/frontend/src/components/SmartWidget/widgetHelpers.tsx b/frontend/src/components/SmartWidget/widgetHelpers.tsx new file mode 100644 index 0000000..73f6d91 --- /dev/null +++ b/frontend/src/components/SmartWidget/widgetHelpers.tsx @@ -0,0 +1,38 @@ +import { humanize } from '../../helpers/humanize'; + +interface DataObject { + [key: string]: any; +} + +export const findFirstNumericKey = ( + obj: Record, +): string | undefined => { + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + const trimmedValue = value.trim(); + + // Only allow numbers, and optionally a single decimal point + const isNumeric = /^-?\d+(\.\d+)?$/.test(trimmedValue); + + if (isNumeric) { + // Check if the number is the same as the trimmed value + // This is to avoid cases like '1.0' being treated as a number + const numberValue = parseFloat(trimmedValue); + if (numberValue.toString() === trimmedValue) { + return key; + } + } + } + } + return undefined; +}; + +export const collectOtherData = ( + obj: DataObject, + excludeKey: string, +): string => { + return Object.entries(obj) + .filter(([key, _]) => key !== excludeKey) + .map(([_, value]) => humanize(value)) + .join(' / '); +}; diff --git a/frontend/src/components/Supervisors/CardSupervisors.tsx b/frontend/src/components/Supervisors/CardSupervisors.tsx new file mode 100644 index 0000000..aa0139d --- /dev/null +++ b/frontend/src/components/Supervisors/CardSupervisors.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + supervisors: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardSupervisors = ({ + supervisors, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SUPERVISORS'); + + return ( +
    + {loading && } +
      + {!loading && + supervisors.map((item, index) => ( +
    • +
      + + {item.user} + + +
      + +
      +
      +
      +
      +
      User
      +
      +
      + {dataFormatter.usersOneListFormatter(item.user)} +
      +
      +
      + +
      +
      + Therapists +
      +
      +
      + {dataFormatter + .therapistsManyListFormatter(item.therapists) + .join(', ')} +
      +
      +
      +
      +
    • + ))} + {!loading && supervisors.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardSupervisors; diff --git a/frontend/src/components/Supervisors/ListSupervisors.tsx b/frontend/src/components/Supervisors/ListSupervisors.tsx new file mode 100644 index 0000000..8bde13c --- /dev/null +++ b/frontend/src/components/Supervisors/ListSupervisors.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + supervisors: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListSupervisors = ({ + supervisors, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SUPERVISORS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + supervisors.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    User

    +

    + {dataFormatter.usersOneListFormatter(item.user)} +

    +
    + +
    +

    Therapists

    +

    + {dataFormatter + .therapistsManyListFormatter(item.therapists) + .join(', ')} +

    +
    + + +
    +
    + ))} + {!loading && supervisors.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListSupervisors; diff --git a/frontend/src/components/Supervisors/TableSupervisors.tsx b/frontend/src/components/Supervisors/TableSupervisors.tsx new file mode 100644 index 0000000..8cab2c0 --- /dev/null +++ b/frontend/src/components/Supervisors/TableSupervisors.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/supervisors/supervisorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureSupervisorsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListSupervisors from './ListSupervisors'; + +const perPage = 10; + +const TableSampleSupervisors = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + supervisors, + loading, + count, + notify: supervisorsNotify, + refetch, + } = useAppSelector((state) => state.supervisors); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (supervisorsNotify.showNotification) { + notify( + supervisorsNotify.typeNotification, + supervisorsNotify.textNotification, + ); + } + }, [supervisorsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `supervisors`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={supervisors ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {supervisors && Array.isArray(supervisors) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleSupervisors; diff --git a/frontend/src/components/Supervisors/configureSupervisorsCols.tsx b/frontend/src/components/Supervisors/configureSupervisorsCols.tsx new file mode 100644 index 0000000..678ad52 --- /dev/null +++ b/frontend/src/components/Supervisors/configureSupervisorsCols.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_SUPERVISORS'); + + return [ + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('users'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'therapists', + headerName: 'Therapists', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + dataFormatter.therapistsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/SwitchField.tsx b/frontend/src/components/SwitchField.tsx new file mode 100644 index 0000000..3b08720 --- /dev/null +++ b/frontend/src/components/SwitchField.tsx @@ -0,0 +1,29 @@ +import React, { useEffect, useId, useState } from 'react'; +import Switch from 'react-switch'; + +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../tailwind.config.js'; +const fullConfig = resolveConfig(tailwindConfig as any); + +export const SwitchField = ({ field, form, disabled }) => { + const handleChange = (data: any) => { + form.setFieldValue(field.name, data); + }; + + const buttonColor = + fullConfig.theme.accentColor.pastelEmeraldTheme?.buttonColor; + const cardColor = fullConfig.theme.accentColor.pastelEmeraldTheme?.cardColor; + + return ( + + ); +}; diff --git a/frontend/src/components/TableSampleClients.tsx b/frontend/src/components/TableSampleClients.tsx new file mode 100644 index 0000000..5eaf9d1 --- /dev/null +++ b/frontend/src/components/TableSampleClients.tsx @@ -0,0 +1,149 @@ +import { mdiEye, mdiTrashCan } from '@mdi/js'; +import React, { useState } from 'react'; +import { useSampleClients } from '../hooks/sampleData'; +import { Client } from '../interfaces'; +import BaseButton from './BaseButton'; +import BaseButtons from './BaseButtons'; +import CardBoxModal from './CardBoxModal'; +import UserAvatar from './UserAvatar'; + +const TableSampleClients = () => { + const { clients } = useSampleClients(); + + const perPage = 5; + + const [currentPage, setCurrentPage] = useState(0); + + const clientsPaginated = clients.slice( + perPage * currentPage, + perPage * (currentPage + 1), + ); + + const numPages = clients.length / perPage; + + const pagesList = []; + + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + return ( + <> + +

    + Lorem ipsum dolor sit amet adipiscing elit +

    +

    This is sample modal

    +
    + + +

    + Lorem ipsum dolor sit amet adipiscing elit +

    +

    This is sample modal

    +
    + + + + + + + + + + + + + {clientsPaginated.map((client: Client) => ( + + + + + + + + + + ))} + +
    + NameCompanyCityProgressCreated +
    + + {client.name}{client.company}{client.city} + + {client.progress} + + + + {client.created} + + + + setIsModalInfoActive(true)} + small + /> + setIsModalTrashActive(true)} + small + /> + +
    +
    +
    + + {pagesList.map((page) => ( + setCurrentPage(page)} + /> + ))} + + + Page {currentPage + 1} of {numPages} + +
    +
    + + ); +}; + +export default TableSampleClients; diff --git a/frontend/src/components/Therapists/CardTherapists.tsx b/frontend/src/components/Therapists/CardTherapists.tsx new file mode 100644 index 0000000..8ad2fce --- /dev/null +++ b/frontend/src/components/Therapists/CardTherapists.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + therapists: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardTherapists = ({ + therapists, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_THERAPISTS'); + + return ( +
    + {loading && } +
      + {!loading && + therapists.map((item, index) => ( +
    • +
      + + {item.specialization} + + +
      + +
      +
      +
      +
      +
      User
      +
      +
      + {dataFormatter.usersOneListFormatter(item.user)} +
      +
      +
      + +
      +
      + Specialization +
      +
      +
      + {item.specialization} +
      +
      +
      +
      +
    • + ))} + {!loading && therapists.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardTherapists; diff --git a/frontend/src/components/Therapists/ListTherapists.tsx b/frontend/src/components/Therapists/ListTherapists.tsx new file mode 100644 index 0000000..95712ae --- /dev/null +++ b/frontend/src/components/Therapists/ListTherapists.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + therapists: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListTherapists = ({ + therapists, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_THERAPISTS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + therapists.map((item) => ( + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    User

    +

    + {dataFormatter.usersOneListFormatter(item.user)} +

    +
    + +
    +

    Specialization

    +

    {item.specialization}

    +
    + + +
    +
    + ))} + {!loading && therapists.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListTherapists; diff --git a/frontend/src/components/Therapists/TableTherapists.tsx b/frontend/src/components/Therapists/TableTherapists.tsx new file mode 100644 index 0000000..8e4b43e --- /dev/null +++ b/frontend/src/components/Therapists/TableTherapists.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/therapists/therapistsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureTherapistsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListTherapists from './ListTherapists'; + +const perPage = 10; + +const TableSampleTherapists = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + therapists, + loading, + count, + notify: therapistsNotify, + refetch, + } = useAppSelector((state) => state.therapists); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (therapistsNotify.showNotification) { + notify( + therapistsNotify.typeNotification, + therapistsNotify.textNotification, + ); + } + }, [therapistsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `therapists`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={therapists ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {therapists && Array.isArray(therapists) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleTherapists; diff --git a/frontend/src/components/Therapists/configureTherapistsCols.tsx b/frontend/src/components/Therapists/configureTherapistsCols.tsx new file mode 100644 index 0000000..3bda2c7 --- /dev/null +++ b/frontend/src/components/Therapists/configureTherapistsCols.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_THERAPISTS'); + + return [ + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('users'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'specialization', + headerName: 'Specialization', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Uploaders/FilesUploader.js b/frontend/src/components/Uploaders/FilesUploader.js new file mode 100644 index 0000000..c68ffb9 --- /dev/null +++ b/frontend/src/components/Uploaders/FilesUploader.js @@ -0,0 +1,133 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import FileUploader from 'components/FormItems/uploaders/UploadService'; +import Errors from '../../../components/FormItems/error/errors'; + +const FilesUploader = (props) => { + const { value, onChange, schema, path, max, readonly } = props; + + const [loading, setLoading] = useState(false); + const inputElement = useRef(null); + + const valuesArr = () => { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; + }; + + const fileList = () => { + return valuesArr().map((item) => ({ + uid: item.id || undefined, + name: item.name, + status: 'done', + url: item.publicUrl, + })); + }; + + const handleRemove = (id) => { + onChange(valuesArr().filter((item) => item.id !== id)); + }; + + const handleChange = async (event) => { + try { + const files = event.target.files; + + if (!files || !files.length) { + return; + } + + let file = files[0]; + + FileUploader.validate(file, schema); + + setLoading(true); + + file = await FileUploader.upload(path, file, schema); + + inputElement.current.value = ''; + setLoading(false); + onChange([...valuesArr(), file]); + } catch (error) { + inputElement.current.value = ''; + console.log('error', error); + setLoading(false); + Errors.showMessage(error); + } + }; + + const formats = () => { + if (schema && schema.formats) { + return schema.formats.map((format) => `.${format}`).join(','); + } + return undefined; + }; + + const uploadButton = ( + + ); + + return ( +
    + {readonly || (max && fileList().length >= max) ? null : uploadButton} + + {valuesArr() && valuesArr().length ? ( +
    + {valuesArr().map((item) => { + return ( +
    + + + {item.name} + + + {!readonly && ( + + )} +
    + ); + })} +
    + ) : null} +
    + ); +}; + +FilesUploader.propTypes = { + readonly: PropTypes.bool, + path: PropTypes.string, + max: PropTypes.number, + schema: PropTypes.shape({ + image: PropTypes.bool, + size: PropTypes.number, + formats: PropTypes.arrayOf(PropTypes.string), + }), + value: PropTypes.any, + onChange: PropTypes.func, +}; + +export default FilesUploader; diff --git a/frontend/src/components/Uploaders/ImagesUploader.js b/frontend/src/components/Uploaders/ImagesUploader.js new file mode 100644 index 0000000..f4e14d0 --- /dev/null +++ b/frontend/src/components/Uploaders/ImagesUploader.js @@ -0,0 +1,227 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import SearchIcon from '@mui/icons-material/Search'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import Dialog from '@mui/material/Dialog'; +import FileUploader from 'components/FormItems/uploaders/UploadService'; +import Errors from '../../../components/FormItems/error/errors'; +import { makeStyles } from '@mui/styles'; + +const useStyles = makeStyles({ + actionButtonsWrapper: { + position: 'relative', + }, + previewContent: { + padding: '0px !important', + }, + imageItem: { + '&.MuiGrid-root': { + margin: 10, + boxShadow: '2px 2px 8px 0 rgb(0 0 0 / 40%)', + borderRadius: 10, + }, + height: '100px', + }, + actionButtons: { + position: 'absolute', + bottom: 5, + right: 4, + }, + previewContainer: { + '& button': { + position: 'absolute', + top: 10, + right: 10, + '& svg': { + height: 50, + width: 50, + fill: '#FFF', + stroke: '#909090', + strokeWidth: 0.5, + }, + }, + }, + button: { + padding: '0px !important', + minWidth: '45px !important', + '& svg': { + height: 36, + width: 36, + fill: '#FFF', + stroke: '#909090', + strokeWidth: 0.5, + }, + }, +}); + +const ImagesUploader = (props) => { + const classes = useStyles(); + const { value, onChange, schema, path, max, readonly, name } = props; + + const [loading, setLoading] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [imageMeta, setImageMeta] = useState({ + imageSrc: null, + imageAlt: null, + }); + const inputElement = useRef(null); + + const valuesArr = () => { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; + }; + + const fileList = () => { + return valuesArr().map((item) => ({ + uid: item.id || undefined, + name: item.name, + status: 'done', + url: item.publicUrl, + })); + }; + + const handleRemove = (id) => { + onChange(valuesArr().filter((item) => item.id !== id)); + }; + + const handleChange = async (event) => { + try { + const files = event.target.files; + + if (!files || !files.length) { + return; + } + + let file = files[0]; + + FileUploader.validate(file, schema); + + setLoading(true); + + file = await FileUploader.upload(path, file, schema); + + inputElement.current.value = ''; + setLoading(false); + onChange([...valuesArr(), file]); + } catch (error) { + inputElement.current.value = ''; + console.log('error', error); + setLoading(false); + Errors.showMessage(error); + } + }; + + const doPreviewImage = (image) => { + setImageMeta({ + imageSrc: image.publicUrl, + imageAlt: image.name, + }); + setShowPreview(true); + }; + + const doCloseImageModal = () => { + setImageMeta({ + imageSrc: null, + imageAlt: null, + }); + setShowPreview(false); + }; + + const uploadButton = ( + + + + ); + + return ( + + {readonly || (max && fileList().length >= max) ? null : uploadButton} + + {valuesArr() && valuesArr().length ? ( + + {valuesArr().map((item) => { + return ( + + {item.name} + +
    +
    + + {!readonly && ( + + )} +
    +
    +
    + ); + })} +
    + ) : null} + + + {imageMeta.imageAlt} + +
    + ); +}; + +ImagesUploader.propTypes = { + readonly: PropTypes.bool, + path: PropTypes.string, + max: PropTypes.number, + schema: PropTypes.shape({ + image: PropTypes.bool, + size: PropTypes.number, + formats: PropTypes.arrayOf(PropTypes.string), + }), + value: PropTypes.any, + onChange: PropTypes.func, + name: PropTypes.string, +}; + +export default ImagesUploader; diff --git a/frontend/src/components/Uploaders/UploadService.js b/frontend/src/components/Uploaders/UploadService.js new file mode 100644 index 0000000..19c5304 --- /dev/null +++ b/frontend/src/components/Uploaders/UploadService.js @@ -0,0 +1,82 @@ +import { v4 as uuidv4 } from 'uuid'; +import Axios from 'axios'; +import { baseURLApi } from '../../config'; + +function extractExtensionFrom(filename) { + if (!filename) { + return null; + } + + const regex = /(?:\.([^.]+))?$/; + return regex.exec(filename)[1]; +} + +export default class FileUploader { + static validate(file, schema) { + if (!schema) { + return; + } + + if (schema.image) { + if (!file.type.startsWith('image')) { + throw new Error('You must upload an image'); + } + } + + if (schema.size && file.size > schema.size) { + throw new Error('File is too big.'); + } + + const extension = extractExtensionFrom(file.name); + + if (schema.formats && !schema.formats.includes(extension)) { + throw new Error('Invalid format'); + } + } + + static async upload(path, file, schema) { + try { + this.validate(file, schema); + } catch (error) { + return Promise.reject(error); + } + + const extension = extractExtensionFrom(file.name); + const id = uuidv4(); + const filename = `${id}.${extension}`; + const privateUrl = `${path}/${filename}`; + + const publicUrl = await this.uploadToServer(file, path, filename); + + return { + id: id, + name: file.name, + sizeInBytes: file.size, + privateUrl, + publicUrl, + new: true, + }; + } + + static async uploadToServer(file, path, filename) { + const formData = new FormData(); + formData.append('file', file); + formData.append('filename', filename); + const uri = `/file/upload/${path}`; + await Axios.post(uri, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const privateUrl = `${path}/${filename}`; + + console.log( + 'process.env.NODE_ENV in uploadToServer function', + process.env.NODE_ENV, + ); + console.log('baseURLApi in uploadToServer function', baseURLApi); + + return `${baseURLApi}/file/download?privateUrl=${privateUrl}`; + } +} diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..a517a4c --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,48 @@ +/* eslint-disable @next/next/no-img-element */ +// Why disabled: +// avatars.dicebear.com provides svg avatars +// next/image needs dangerouslyAllowSVG option for that + +import React, { ReactNode } from 'react'; +import BaseIcon from './BaseIcon'; +import { mdiAccountCircleOutline } from '@mdi/js'; + +type Props = { + username: string; + avatar?: string | null; + image?: object | null; + api?: string; + className?: string; + children?: ReactNode; +}; + +export default function UserAvatar({ + username, + image, + avatar, + className = '', + children, +}: Props) { + const avatarImage = image && image[0] ? `${image[0].publicUrl}` : '#'; + + return ( +
    + {avatarImage === '#' ? ( + + ) : ( + {username} + )} + {children} +
    + ); +} diff --git a/frontend/src/components/UserAvatarCurrentUser.tsx b/frontend/src/components/UserAvatarCurrentUser.tsx new file mode 100644 index 0000000..1bcf833 --- /dev/null +++ b/frontend/src/components/UserAvatarCurrentUser.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode, useEffect, useState } from 'react'; +import { useAppSelector } from '../stores/hooks'; +import UserAvatar from './UserAvatar'; + +type Props = { + className?: string; + children?: ReactNode; +}; + +export default function UserAvatarCurrentUser({ + className = '', + children, +}: Props) { + const userName = useAppSelector((state) => state.main.userName); + const userAvatar = useAppSelector((state) => state.main.userAvatar); + const { currentUser, isFetching, token } = useAppSelector( + (state) => state.auth, + ); + const { users, loading } = useAppSelector((state) => state.users); + + const [avatar, setAvatar] = useState(null); + + useEffect(() => { + currentUserAvatarCheck(); + }, []); + + useEffect(() => { + currentUserAvatarCheck(); + }, [currentUser?.id, users]); + + const currentUserAvatarCheck = () => { + if (currentUser?.id) { + const image = currentUser?.avatar; + setAvatar(image); + } + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx new file mode 100644 index 0000000..15fc7e5 --- /dev/null +++ b/frontend/src/components/UserCard.tsx @@ -0,0 +1,47 @@ +import { mdiCheckDecagram } from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import { useAppSelector } from '../stores/hooks'; +import CardBox from './CardBox'; +import FormCheckRadio from './FormCheckRadio'; +import UserAvatarCurrentUser from './UserAvatarCurrentUser'; + +type Props = { + className?: string; +}; + +const UserCard = ({ className }: Props) => { + const userName = useAppSelector((state) => state.main.userName); + + return ( + +
    + +
    +
    + alert(JSON.stringify(values, null, 2))} + > +
    + + + +
    +
    +
    +

    + Howdy, {userName}! +

    +

    + Last login 12 mins ago from 127.0.0.1 +

    +
    Verified
    +
    +
    +
    + ); +}; + +export default UserCard; diff --git a/frontend/src/components/Users/CardUsers.tsx b/frontend/src/components/Users/CardUsers.tsx new file mode 100644 index 0000000..f4ce406 --- /dev/null +++ b/frontend/src/components/Users/CardUsers.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + users: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardUsers = ({ + users, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS'); + + return ( +
    + {loading && } +
      + {!loading && + users.map((item, index) => ( +
    • +
      + + +

      {item.firstName}

      + + +
      + +
      +
      +
      +
      +
      + First Name +
      +
      +
      + {item.firstName} +
      +
      +
      + +
      +
      + Last Name +
      +
      +
      + {item.lastName} +
      +
      +
      + +
      +
      + Phone Number +
      +
      +
      + {item.phoneNumber} +
      +
      +
      + +
      +
      + E-Mail +
      +
      +
      {item.email}
      +
      +
      + +
      +
      + Disabled +
      +
      +
      + {dataFormatter.booleanFormatter(item.disabled)} +
      +
      +
      + +
      +
      + Avatar +
      +
      +
      + +
      +
      +
      + +
      +
      + App Role +
      +
      +
      + {dataFormatter.rolesOneListFormatter(item.app_role)} +
      +
      +
      + +
      +
      + Custom Permissions +
      +
      +
      + {dataFormatter + .permissionsManyListFormatter(item.custom_permissions) + .join(', ')} +
      +
      +
      +
      +
    • + ))} + {!loading && users.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardUsers; diff --git a/frontend/src/components/Users/ListUsers.tsx b/frontend/src/components/Users/ListUsers.tsx new file mode 100644 index 0000000..7fceddd --- /dev/null +++ b/frontend/src/components/Users/ListUsers.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + users: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListUsers = ({ + users, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + users.map((item) => ( + +
    + + + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    First Name

    +

    {item.firstName}

    +
    + +
    +

    Last Name

    +

    {item.lastName}

    +
    + +
    +

    Phone Number

    +

    {item.phoneNumber}

    +
    + +
    +

    E-Mail

    +

    {item.email}

    +
    + +
    +

    Disabled

    +

    + {dataFormatter.booleanFormatter(item.disabled)} +

    +
    + +
    +

    Avatar

    + +
    + +
    +

    App Role

    +

    + {dataFormatter.rolesOneListFormatter(item.app_role)} +

    +
    + +
    +

    + Custom Permissions +

    +

    + {dataFormatter + .permissionsManyListFormatter(item.custom_permissions) + .join(', ')} +

    +
    + + +
    +
    + ))} + {!loading && users.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListUsers; diff --git a/frontend/src/components/Users/TableUsers.tsx b/frontend/src/components/Users/TableUsers.tsx new file mode 100644 index 0000000..ce6fbe7 --- /dev/null +++ b/frontend/src/components/Users/TableUsers.tsx @@ -0,0 +1,485 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureUsersCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleUsers = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + users, + loading, + count, + notify: usersNotify, + refetch, + } = useAppSelector((state) => state.users); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (usersNotify.showNotification) { + notify(usersNotify.typeNotification, usersNotify.textNotification); + } + }, [usersNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `users`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + delete data?.password; + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={users ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleUsers; diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx new file mode 100644 index 0000000..d75e835 --- /dev/null +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS'); + + return [ + { + field: 'firstName', + headerName: 'First Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'lastName', + headerName: 'Last Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'phoneNumber', + headerName: 'Phone Number', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'email', + headerName: 'E-Mail', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'disabled', + headerName: 'Disabled', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'boolean', + }, + + { + field: 'avatar', + headerName: 'Avatar', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + renderCell: (params: GridValueGetterParams) => ( + + ), + }, + + { + field: 'app_role', + headerName: 'App Role', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('roles'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'custom_permissions', + headerName: 'Custom Permissions', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + dataFormatter.permissionsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageLeft.tsx b/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageLeft.tsx new file mode 100644 index 0000000..b02478c --- /dev/null +++ b/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageLeft.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../../helpers/pexels'; +import { useAppSelector } from '../../../../stores/hooks'; +import BaseButton from '../../../BaseButton'; + +const ImageLeft = ({ + projectName, + mainText, + subTitle, + imageAbout, + buttonText, + corners, + textSecondary, +}) => { + return ( +
    +
    + + + {/* Text Section (Теперь справа) */} +
    +

    {mainText}

    +

    {subTitle}

    + +
    +
    +
    + ); +}; + +export default ImageLeft; diff --git a/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageRight.tsx b/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageRight.tsx new file mode 100644 index 0000000..3af73eb --- /dev/null +++ b/frontend/src/components/WebPageComponents/AboutUsComponent/designs/ImageRight.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../../helpers/pexels'; +import { useAppSelector } from '../../../../stores/hooks'; +import BaseButton from '../../../BaseButton'; + +const ImageRight = ({ + projectName, + mainText, + subTitle, + imageAbout, + buttonText, + corners, + textSecondary, +}) => { + return ( +
    +
    +
    +

    {mainText}

    +

    {subTitle}

    + +
    + + +
    +
    + ); +}; + +export default ImageRight; diff --git a/frontend/src/components/WebPageComponents/AboutUsComponent/index.tsx b/frontend/src/components/WebPageComponents/AboutUsComponent/index.tsx new file mode 100644 index 0000000..1c19eb0 --- /dev/null +++ b/frontend/src/components/WebPageComponents/AboutUsComponent/index.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../helpers/pexels'; +import { useAppSelector } from '../../../stores/hooks'; +import { AboutUsDesigns } from '../designs'; +import ImageLeft from './designs/ImageLeft'; +import ImageRight from './designs/ImageRight'; + +const AboutUsComponent = ({ + projectName, + image, + mainText, + subTitle, + design, + buttonText, +}) => { + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const corners = useAppSelector((state) => state.style.corners); + const [imageAbout, setImages] = useState([]); + const pexelsQueriesWebSite = image; + + useEffect(() => { + const fetchImages = async () => { + try { + const images = await getMultiplePexelsImages(pexelsQueriesWebSite); + const formattedImages = images.map((image) => ({ + src: image.src || undefined, + photographer: image.photographer || undefined, + photographer_url: image.photographer_url || undefined, + })); + setImages(formattedImages); + } catch (error) { + console.error('Error fetching images:', error); + } + }; + + fetchImages(); + }, [pexelsQueriesWebSite]); + + const renderComponent = () => { + switch (design) { + case AboutUsDesigns.IMAGE_LEFT: + return ( + + ); + + case AboutUsDesigns.IMAGE_RIGHT: + return ( + + ); + + default: + return ( + + ); + } + }; + + return renderComponent(); +}; + +export default AboutUsComponent; diff --git a/frontend/src/components/WebPageComponents/ContactFormComponent/designs/FormWithImage.tsx b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/FormWithImage.tsx new file mode 100644 index 0000000..1a94a31 --- /dev/null +++ b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/FormWithImage.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import BaseButton from '../../../BaseButton'; +import FormField from '../../../../components/FormField'; +import { useAppSelector, useAppDispatch } from '../../../../stores/hooks'; + +const FormWithImage = ({ mainText, subTitle, onSubmit, imageContactForm }) => { + const corners = useAppSelector((state) => state.style.corners); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + return ( +
    +
    +
    + + +
    +
    +

    {mainText}

    +

    {subTitle}

    + + + {({ isSubmitting }) => ( +
    + + + + + + + + + + + + + + + )} +
    +
    +
    +
    +
    +
    + ); +}; +export default FormWithImage; diff --git a/frontend/src/components/WebPageComponents/ContactFormComponent/designs/HighlightedForm.tsx b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/HighlightedForm.tsx new file mode 100644 index 0000000..dd0e5de --- /dev/null +++ b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/HighlightedForm.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import BaseButton from '../../../BaseButton'; +import BaseIcon from '../../../BaseIcon'; +import * as icon from '@mdi/js'; +import { ContactFormDesigns } from '../../designs'; +import FormField from '../../../../components/FormField'; +import { useAppSelector, useAppDispatch } from '../../../../stores/hooks'; +const HighlightedForm = ({ mainText, subTitle, onSubmit, design }) => { + const corners = useAppSelector((state) => state.style.corners); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + + return ( +
    +
    +
    +
    +

    {mainText}

    +

    {subTitle}

    +
    +

    + + +1XXX XXXX XXX +

    +

    + + + krystsinavaida@gmail.com + +

    +
    +
    +
    + + {({ isSubmitting }) => ( +
    + + + + + + + + + + + + + + + )} +
    +
    +
    +
    +
    + ); +}; + +export default HighlightedForm; diff --git a/frontend/src/components/WebPageComponents/ContactFormComponent/designs/SimpleAndCleanForm.tsx b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/SimpleAndCleanForm.tsx new file mode 100644 index 0000000..6ff4034 --- /dev/null +++ b/frontend/src/components/WebPageComponents/ContactFormComponent/designs/SimpleAndCleanForm.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import BaseButton from '../../../BaseButton'; +import FormField from '../../../../components/FormField'; +import BaseIcon from '../../../BaseIcon'; +import * as icon from '@mdi/js'; +import { ContactFormDesigns } from '../../designs'; +import { useAppSelector, useAppDispatch } from '../../../../stores/hooks'; +const SimpleAndCleanForm = ({ mainText, subTitle, onSubmit, design }) => { + const corners = useAppSelector((state) => state.style.corners); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + return ( +
    +
    +
    +
    +

    {mainText}

    +

    + {subTitle} +

    +
    +

    + + +1XXX XXXX XXX +

    +

    + + + guruvishnukurra800@gmail.com + +

    +
    +
    +
    + + {({ isSubmitting }) => ( +
    + + + + + + + + + + + + + + + )} +
    +
    +
    +
    +
    + ); +}; + +export default SimpleAndCleanForm; diff --git a/frontend/src/components/WebPageComponents/ContactFormComponent/index.tsx b/frontend/src/components/WebPageComponents/ContactFormComponent/index.tsx new file mode 100644 index 0000000..65210af --- /dev/null +++ b/frontend/src/components/WebPageComponents/ContactFormComponent/index.tsx @@ -0,0 +1,89 @@ +// src/components/WebPageComponents/ContactFormSection.tsx +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../helpers/pexels'; +import { useAppSelector } from '../../../stores/hooks'; +import { ContactFormDesigns } from '../designs'; +import SimpleAndCleanForm from './designs/SimpleAndCleanForm'; +import HighlightedForm from './designs/HighlightedForm'; +import FormWithImage from './designs/FormWithImage'; +import { ToastContainer, toast } from 'react-toastify'; +import axios from 'axios'; +import 'react-toastify/dist/ReactToastify.css'; + +export default function ContactFormSection({ + projectName, + withBg = 0, + mainText, + subTitle, + design, + image, +}) { + const [imageContactForm, setImages] = useState([]); + const pexelsQueriesWebSite = image; + const textSecondary = useAppSelector((state) => state.style.textSecondary); + + useEffect(() => { + const fetchImages = async () => { + if (design === ContactFormDesigns.WITH_IMAGE) { + try { + const images = await getMultiplePexelsImages(pexelsQueriesWebSite); + const formattedImages = images.map((image) => ({ + src: image.src || undefined, + photographer: image.photographer || undefined, + photographer_url: image.photographer_url || undefined, + })); + setImages(formattedImages); + } catch (error) { + console.error('Error fetching images:', error); + } + } + }; + + fetchImages(); + }, [pexelsQueriesWebSite, design]); + + const handleSubmit = async (values, { setSubmitting, resetForm }) => { + try { + await axios.post('/contact-form/send', values); + toast.success('Your message has been sent successfully!'); + resetForm(); + } catch (error) { + toast.error('There was an error sending your message'); + } finally { + setSubmitting(false); + } + }; + + let DesignComponent; + + switch (design) { + case ContactFormDesigns.SIMPLE_CLEAN: + case ContactFormDesigns.SIMPLE_CLEAN_DIVERSITY: + DesignComponent = SimpleAndCleanForm; + break; + case ContactFormDesigns.HIGHLIGHTED: + case ContactFormDesigns.HIGHLIGHTED_DIVERSITY: + DesignComponent = HighlightedForm; + break; + case ContactFormDesigns.WITH_IMAGE: + DesignComponent = FormWithImage; + break; + default: + DesignComponent = SimpleAndCleanForm; + break; + } + + return ( +
    + + +
    + ); +} diff --git a/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQAccordion.tsx b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQAccordion.tsx new file mode 100644 index 0000000..ee7cf42 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQAccordion.tsx @@ -0,0 +1,78 @@ +// components/FAQ/FAQAccordion.js +import React, { useState } from 'react'; + +const FAQAccordion = ({ + faqs, + projectName, + textSecondary, + corners, + borders, + mainText, +}) => { + const [openIndex, setOpenIndex] = useState(null); + + const toggleFAQ = (index) => { + setOpenIndex(openIndex === index ? null : index); + }; + + return ( +
    +
    +
    +
    +

    + {mainText} +

    +
    + +
    + {faqs.map((faq, index) => ( +
    +
    toggleFAQ(index)} + > +

    + {faq.question.replace(/\${projectName}/g, projectName)} +

    + + {openIndex === index ? '−' : '+'} + +
    + +
    +
    +

    {faq.answer.replace(/\${projectName}/g, projectName)}

    +
    +
    +
    + ))} +
    +
    +
    +
    + ); +}; + +export default FAQAccordion; diff --git a/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQSplitList.tsx b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQSplitList.tsx new file mode 100644 index 0000000..02b7407 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQSplitList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { FaqDesigns } from '../../designs'; + +const FAQSplitList = ({ + faqs, + projectName, + textSecondary, + borders, + mainText, + websiteSectionStyle, + design, +}) => ( +
    +
    +
    +
    +

    + {mainText} +

    +
    + +
    + {faqs.map((faq, index) => ( +
    +
    +

    + {faq.question.replace(/\${projectName}/g, projectName)} +

    +
    + +
    +

    + {faq.answer.replace(/\${projectName}/g, projectName)} +

    +
    +
    + ))} +
    +
    +
    +
    +); + +export default FAQSplitList; diff --git a/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQTwoColumn.tsx b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQTwoColumn.tsx new file mode 100644 index 0000000..85c61d6 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FaqComponent/designs/FAQTwoColumn.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const FAQTwoColumn = ({ faqs, projectName, textSecondary, mainText }) => ( +
    +
    +
    +
    +

    + FAQ +

    +

    {mainText}

    +
    +
    + {faqs.map((faq, index) => ( +
    +

    + {faq.question.replace(/\${projectName}/g, projectName)} +

    +

    + {faq.answer.replace(/\${projectName}/g, projectName)} +

    +
    + ))} +
    +
    +
    +
    +); + +export default FAQTwoColumn; diff --git a/frontend/src/components/WebPageComponents/FaqComponent/index.tsx b/frontend/src/components/WebPageComponents/FaqComponent/index.tsx new file mode 100644 index 0000000..edc77fe --- /dev/null +++ b/frontend/src/components/WebPageComponents/FaqComponent/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useAppSelector } from '../../../stores/hooks'; +import FAQAccordion from './designs/FAQAccordion'; +import FAQSplitList from './designs/FAQSplitList'; +import FAQTwoColumn from './designs/FAQTwoColumn'; +import { FaqDesigns } from '../designs'; + +const FaqSection = ({ projectName, mainText, faqs, design }) => { + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const borders = useAppSelector((state) => state.style.borders); + const corners = useAppSelector((state) => state.style.corners); + const websiteSectionStyle = useAppSelector( + (state) => state.style.websiteSectionStyle, + ); + + let designComponent; + + switch (design) { + case FaqDesigns.ACCORDION: + designComponent = ( + + ); + break; + + case FaqDesigns.SPLIT_LIST: + case FaqDesigns.SPLIT_LIST_DIVERSITY: + designComponent = ( + + ); + break; + + case FaqDesigns.TWO_COLUMN: + designComponent = ( + + ); + break; + + default: + designComponent = ( + + ); + break; + } + + return
    {designComponent}
    ; +}; + +export default FaqSection; diff --git a/frontend/src/components/WebPageComponents/FeaturesComponent/designs/CardsGridWithIcons.tsx b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/CardsGridWithIcons.tsx new file mode 100644 index 0000000..b77cc91 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/CardsGridWithIcons.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import BaseIcon from '../../../BaseIcon'; +import * as icon from '@mdi/js'; +import { FeaturesDesigns } from '../../designs'; + +const CardsGridWithIcons = ({ + features, + projectName, + design, + iconsColor, + corners, + mainText, + subTitle, + websiteSectionStyle, + textSecondary, + borders, + shadow, +}) => ( +
    +
    +
    +

    {mainText}

    +

    {subTitle}

    +
    + +
    + {features.map((feature: any, index) => ( +
    +
    +

    + {feature.name.replace(/\${projectName}/g, projectName)} +

    +

    + {feature.description.replace(/\${projectName}/g, projectName)} +

    +
    +
    + +
    +
    + ))} +
    +
    +
    +); + +export default CardsGridWithIcons; diff --git a/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsTop.tsx b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsTop.tsx new file mode 100644 index 0000000..37c6cb1 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsTop.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import BaseIcon from '../../../BaseIcon'; +import * as icon from '@mdi/js'; + +const IconsTop = ({ + features, + projectName, + withBg, + iconsColor, + mainText, + subTitle, + textSecondary, + websiteSectionStyle, +}) => ( +
    +
    +
    +

    {mainText}

    +

    {subTitle}

    +
    + +
    + {features.map((feature: any, index) => ( +
    +
    + +
    +
    +

    + {feature.name.replace(/\${projectName}/g, projectName)} +

    +

    + {feature.description.replace(/\${projectName}/g, projectName)} +

    +
    +
    + ))} +
    +
    +
    +); + +export default IconsTop; diff --git a/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsWithImage.tsx b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsWithImage.tsx new file mode 100644 index 0000000..cd0bb88 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/IconsWithImage.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import BaseIcon from '../../../BaseIcon'; +import * as icon from '@mdi/js'; + +const IconsWithImage = ({ + features, + projectName, + withBg, + iconsColor, + mainText, + subTitle, + image, + textSecondary, + websiteSectionStyle, + corners, +}) => { + const displayedFeatures = features.slice(0, 4); + + return ( +
    +
    +
    + + +
    +
    + {displayedFeatures.map((feature, index) => ( +
    +
    + +
    + + {/* Текст */} +
    +

    + {feature.name.replace(/\${projectName}/g, projectName)} +

    +

    + {feature.description.replace( + /\${projectName}/g, + projectName, + )} +

    +
    +
    + ))} +
    +
    +
    +
    +
    + ); +}; + +export default IconsWithImage; diff --git a/frontend/src/components/WebPageComponents/FeaturesComponent/designs/LargeNumbers.tsx b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/LargeNumbers.tsx new file mode 100644 index 0000000..6ffe9bb --- /dev/null +++ b/frontend/src/components/WebPageComponents/FeaturesComponent/designs/LargeNumbers.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +const LargeNumbers = ({ + features, + projectName, + withBg, + iconsColor, + mainText, + subTitle, + textSecondary, + websiteSectionStyle, +}) => ( +
    +
    +
    +

    {mainText}

    +

    {subTitle}

    +
    + +
    + {features.map((feature: any, index) => ( +
    +
    +
    + + {index + 1} + +
    +
    +
    +

    + {feature.name.replace(/\${projectName}/g, projectName)} +

    +

    + {feature.description.replace(/\${projectName}/g, projectName)} +

    +
    +
    + ))} +
    +
    +
    +); + +export default LargeNumbers; diff --git a/frontend/src/components/WebPageComponents/FeaturesComponent/index.tsx b/frontend/src/components/WebPageComponents/FeaturesComponent/index.tsx new file mode 100644 index 0000000..7eb2916 --- /dev/null +++ b/frontend/src/components/WebPageComponents/FeaturesComponent/index.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../helpers/pexels'; +import { useAppSelector } from '../../../stores/hooks'; +import IconsTop from './designs/IconsTop'; +import LargeNumbers from './designs/LargeNumbers'; +import CardsGridWithIcons from './designs/CardsGridWithIcons'; +import { FeaturesDesigns } from '../designs'; +import IconsWithImage from './designs/IconsWithImage'; + +export default function FeaturesSection({ + projectName, + withBg = 0, + features, + mainText, + subTitle, + design, + image, +}) { + const textColor = useAppSelector((state) => state.style.linkColor); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const corners = useAppSelector((state) => state.style.corners); + const shadow = useAppSelector((state) => state.style.shadow); + const websiteSectionStyle = useAppSelector( + (state) => state.style.websiteSectionStyle, + ); + const borders = useAppSelector((state) => state.style.borders); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const pexelsQueriesWebSite = image; + const [imageFeatures, setImages] = useState([]); + useEffect(() => { + const fetchImages = async () => { + if (design === FeaturesDesigns.ICONS_WITH_IMAGE) { + try { + const images = await getMultiplePexelsImages(pexelsQueriesWebSite); + const formattedImages = images.map((image) => ({ + src: image.src || undefined, + photographer: image.photographer || undefined, + photographer_url: image.photographer_url || undefined, + })); + setImages(formattedImages); + } catch (error) { + console.error('Error fetching images:', error); + } + } + }; + + fetchImages(); + }, [pexelsQueriesWebSite, design]); + + let designComponent; + + switch (design) { + case FeaturesDesigns.ICONS_TOP: + designComponent = ( + + ); + break; + + case FeaturesDesigns.LARGE_NUMBERS: + designComponent = ( + + ); + break; + + case FeaturesDesigns.CARDS_GRID_WITH_ICONS: + case FeaturesDesigns.CARDS_GRID_WITH_ICONS_DIVERSITY: + designComponent = ( + + ); + break; + + case FeaturesDesigns.ICONS_WITH_IMAGE: + designComponent = ( + + ); + break; + + default: + designComponent = ( + + ); + break; + } + + return
    {designComponent}
    ; +} diff --git a/frontend/src/components/WebPageComponents/Footer.tsx b/frontend/src/components/WebPageComponents/Footer.tsx new file mode 100644 index 0000000..9c8905e --- /dev/null +++ b/frontend/src/components/WebPageComponents/Footer.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { FooterStyle } from './designs'; +import { FooterDesigns } from './designs'; +import Link from 'next/link'; +import { humanize } from '../../helpers/humanize'; +import { useRouter } from 'next/router'; +import { useAppSelector } from '../../stores/hooks'; +interface WebSiteFooterProps { + projectName: string; + pages: any; +} +export default function WebSiteFooter({ + projectName, + pages, +}: WebSiteFooterProps) { + const currentYear = new Date().getFullYear(); + const router = useRouter(); + const borders = useAppSelector((state) => state.style.borders); + const websiteHeder = useAppSelector((state) => state.style.websiteHeder); + + const style = FooterStyle.WITH_PAGES; + + const design = FooterDesigns.DESIGN_DIVERSITY; + + return ( +
    +
    +
    +

    + © {currentYear} Flatlogic. All rights reserved +

    + {style ? ( +

    {projectName}

    + ) : ( +
    + {pages.map((page, index) => { + const isRootRoute = router.pathname === '/'; + const isActive = isRootRoute + ? index === 0 + : router.pathname.includes(page.href); + + return ( + + {humanize(page.label)} + + ); + })} +
    + )} +
    + + Terms of Use + + + Privacy Policy + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/HorizontalGalleryWithButtons.tsx b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/HorizontalGalleryWithButtons.tsx new file mode 100644 index 0000000..732041c --- /dev/null +++ b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/HorizontalGalleryWithButtons.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const HorizontalGallery = ({ + images, + currentIndex, + prevSlide, + nextSlide, + getPrevIndex, + getNextIndex, + mainText, + corners, +}) => { + return ( + + ); +}; + +export default HorizontalGallery; diff --git a/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/OverlappingCentralImage.tsx b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/OverlappingCentralImage.tsx new file mode 100644 index 0000000..6844389 --- /dev/null +++ b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/designs/OverlappingCentralImage.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +const OverlappingGallery = ({ + images, + currentIndex, + prevSlide, + nextSlide, + getPrevIndex, + getNextIndex, + mainText, + corners, +}) => { + const applyLeftCorner = (corners) => { + if (corners === 'rounded-full') { + return 'rounded-l-2xl'; + } + if (corners.startsWith('rounded-lg')) { + return 'rounded-l-lg'; + } + if (corners.startsWith('rounded-md')) { + return 'rounded-l-md'; + } + if (corners.startsWith('rounded-sm')) { + return 'rounded-l-sm'; + } + if (corners.startsWith('rounded-xl')) { + return 'rounded-l-xl'; + } + if (corners.startsWith('rounded')) { + return 'rounded-l'; + } + return corners; + }; + + const applyRightCorner = (corners) => { + if (corners === 'rounded-full') { + return 'rounded-r-2xl'; + } + if (corners.startsWith('rounded')) { + return corners.replace('rounded', 'rounded-r'); + } + return corners.replace('rounded-', 'rounded-r-'); + }; + const buttonCornersLeft = applyLeftCorner(corners); + const buttonCornersRight = applyRightCorner(corners); + return ( + + ); +}; + +export default OverlappingGallery; diff --git a/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/index.tsx b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/index.tsx new file mode 100644 index 0000000..bcd7856 --- /dev/null +++ b/frontend/src/components/WebPageComponents/GalleryPortfolioComponent/index.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import HorizontalGallery from './designs/HorizontalGalleryWithButtons'; +import OverlappingGallery from './designs/OverlappingCentralImage'; +import { GalleryPortfolioDesigns } from '../designs'; +import { useAppSelector } from '../../../stores/hooks'; + +export default function GalleryPortfolioSection({ + projectName, + images, + mainText, + design, +}) { + const [currentIndex, setCurrentIndex] = useState(0); + const corners = useAppSelector((state) => state.style.corners); + const prevSlide = () => { + setCurrentIndex((prevIndex) => + prevIndex === 0 ? images.length - 1 : prevIndex - 1, + ); + }; + + const nextSlide = () => { + setCurrentIndex((prevIndex) => + prevIndex === images.length - 1 ? 0 : prevIndex + 1, + ); + }; + + const getPrevIndex = () => + currentIndex === 0 ? images.length - 1 : currentIndex - 1; + const getNextIndex = () => + currentIndex === images.length - 1 ? 0 : currentIndex + 1; + + switch (design) { + case GalleryPortfolioDesigns.HORIZONTAL_WITH_BUTTONS: + return ( + + ); + + case GalleryPortfolioDesigns.OVERLAPPING_CENTRAL_IMAGE: + return ( + + ); + + default: + return ( + + ); + } +} diff --git a/frontend/src/components/WebPageComponents/Header.tsx b/frontend/src/components/WebPageComponents/Header.tsx new file mode 100644 index 0000000..729ecbe --- /dev/null +++ b/frontend/src/components/WebPageComponents/Header.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import Link from 'next/link'; +import BaseButton from '../BaseButton'; +import { humanize } from '../../helpers/humanize'; +import { useRouter } from 'next/router'; +import { useAppSelector } from '../../stores/hooks'; +import { HeaderStyle } from './designs'; +import { HeaderDesigns } from './designs'; + +interface WebSiteHeaderProps { + projectName: string; + pages: any; +} +export default function WebSiteHeader({ + projectName, + pages, +}: WebSiteHeaderProps) { + const router = useRouter(); + const websiteHeder = useAppSelector((state) => state.style.websiteHeder); + const borders = useAppSelector((state) => state.style.borders); + + const style = HeaderStyle.PAGES_RIGHT; + + const design = HeaderDesigns.DESIGN_DIVERSITY; + return ( +
    +
    +
    +
    +
    +
    + {projectName} +
    +
    + {pages.map((page, index) => { + const isRootRoute = router.pathname === '/'; + const isActive = isRootRoute + ? index === 0 + : router.pathname.includes(page.href); + + return ( + + {humanize(page.label)} + + ); + })} +
    +
    +
    + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageBg.tsx b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageBg.tsx new file mode 100644 index 0000000..53e43ef --- /dev/null +++ b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageBg.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const HeroImageBg = ({ + mainText, + subTitle, + buttonText, + imageHero, + textSecondary, +}) => ( +
    +
    +
    +

    + {mainText} +

    +

    {subTitle}

    + +
    + +
    +); + +export default HeroImageBg; diff --git a/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageLeft.tsx b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageLeft.tsx new file mode 100644 index 0000000..173e82d --- /dev/null +++ b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageLeft.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const HeroImageLeft = ({ + mainText, + subTitle, + buttonText, + imageHero, + textSecondary, +}) => ( +
    +
    +
    +

    + {mainText} +

    +
    +

    {subTitle}

    +
    + +
    +
    + +
    +); + +export default HeroImageLeft; diff --git a/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageRight.tsx b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageRight.tsx new file mode 100644 index 0000000..95184bb --- /dev/null +++ b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroImageRight.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const HeroImageRight = ({ + mainText, + subTitle, + buttonText, + imageHero, + textSecondary, +}) => ( +
    + +
    +
    +

    + {mainText} +

    +
    +

    {subTitle}

    +
    + +
    +
    +
    +); + +export default HeroImageRight; diff --git a/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroTextCenter.tsx b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroTextCenter.tsx new file mode 100644 index 0000000..6bfcbcd --- /dev/null +++ b/frontend/src/components/WebPageComponents/HeroComponent/designs/HeroTextCenter.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const HeroTextCenter = ({ mainText, subTitle, buttonText, textSecondary }) => ( +
    +
    +
    +

    + {mainText} +

    +

    {subTitle}

    + +
    +
    +); + +export default HeroTextCenter; diff --git a/frontend/src/components/WebPageComponents/HeroComponent/index.tsx b/frontend/src/components/WebPageComponents/HeroComponent/index.tsx new file mode 100644 index 0000000..7b178ce --- /dev/null +++ b/frontend/src/components/WebPageComponents/HeroComponent/index.tsx @@ -0,0 +1,73 @@ +// src/components/WebPageComponents/HeroSection.tsx +import React, { useEffect, useState } from 'react'; +import { getMultiplePexelsImages } from '../../../helpers/pexels'; +import { useAppSelector } from '../../../stores/hooks'; +import { HeroDesigns } from '../designs'; +import HeroImageLeft from './designs/HeroImageLeft'; +import HeroImageRight from './designs/HeroImageRight'; +import HeroImageBg from './designs/HeroImageBg'; +import HeroTextCenter from './designs/HeroTextCenter'; + +export default function HeroSection({ + projectName, + image, + mainText, + subTitle, + design, + buttonText, +}) { + const textSecondary = useAppSelector((state) => state.style.textSecondary); + + const [imageHero, setImages] = useState([]); + const pexelsQueriesWebSite = image; + + useEffect(() => { + const fetchImages = async () => { + if (design !== HeroDesigns.TEXT_CENTER) { + try { + const images = await getMultiplePexelsImages(pexelsQueriesWebSite); + const formattedImages = images.map((image) => ({ + src: image.src || undefined, + photographer: image.photographer || undefined, + photographer_url: image.photographer_url || undefined, + })); + setImages(formattedImages); + } catch (error) { + console.error('Error fetching images:', error); + } + } + }; + + fetchImages(); + }, [pexelsQueriesWebSite, design]); + + let DesignComponent; + + switch (design) { + case HeroDesigns.IMAGE_LEFT: + DesignComponent = HeroImageLeft; + break; + case HeroDesigns.IMAGE_RIGHT: + DesignComponent = HeroImageRight; + break; + case HeroDesigns.IMAGE_BG: + DesignComponent = HeroImageBg; + break; + case HeroDesigns.TEXT_CENTER: + DesignComponent = HeroTextCenter; + break; + default: + DesignComponent = HeroImageRight; + break; + } + + return ( + + ); +} diff --git a/frontend/src/components/WebPageComponents/PricingComponent/index.tsx b/frontend/src/components/WebPageComponents/PricingComponent/index.tsx new file mode 100644 index 0000000..40b45b3 --- /dev/null +++ b/frontend/src/components/WebPageComponents/PricingComponent/index.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useAppSelector, useAppDispatch } from '../../../stores/hooks'; +import BaseButton from '../../BaseButton'; +import BaseIcon from '../../BaseIcon'; +import * as icon from '@mdi/js'; + +export default function PricingSection({ + projectName, + withBg = 0, + features, + description, +}) { + const textColor = useAppSelector((state) => state.style.linkColor); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const borders = useAppSelector((state) => state.style.borders); + const shadow = useAppSelector((state) => state.style.shadow); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const websiteSectionStyle = useAppSelector( + (state) => state.style.websiteSectionStyle, + ); + const navBarItemLabelActiveColorStyle = useAppSelector( + (state) => state.style.navBarItemLabelActiveColorStyle, + ); + const asideMenuItemActiveStyle = useAppSelector( + (state) => state.style.asideMenuItemActiveStyle, + ); + const corners = useAppSelector((state) => state.style.corners); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const activeLinkColor = useAppSelector( + (state) => state.style.activeLinkColor, + ); + + return ( +
    +
    +
    +

    + Choose the plan that’s right for you +

    +
    Pricing Table
    +
    +
    +
    +
    +

    Standard

    +

    + {description['standard'] + ? description['standard'] + : 'For solo designer'} +

    +
    +
    +
    + +

    29

    +
    +

    + per person, per month +

    +
    + +
      + {features.standard.features.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    +

    Limited to

    +
      + {features.standard.limited_features.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    +
    + +
    + +
    +
    +

    + Premium{' '} + + MOST POPULAR + +

    +

    + {description['premium'] + ? description['premium'] + : ' For small startup and agency'} +

    +
    +
    +
    + +

    49

    +
    +

    + per person, per month +

    +
    + +
      + {features.premium.features.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    +

    Also Include

    + +
      + {features.premium.also_included.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    +
    + +
    +
    +
    +

    + Business +

    +

    + {description['business'] + ? description['business'] + : ' Custom solution'} +

    +
    +
    +
    + +

    99

    +
    +

    + per person, per month +

    +
    + +
      + {features.business.features.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/HorizontalCarousel.tsx b/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/HorizontalCarousel.tsx new file mode 100644 index 0000000..d7cb15f --- /dev/null +++ b/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/HorizontalCarousel.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; +import { TestimonialsDesigns } from '../../designs'; + +const HorizontalCarousel = ({ + projectName, + testimonials, + currentIndex, + handlePrev, + handleNext, + design, + websiteSectionStyle, + textSecondary, + iconsColor, +}) => { + return ( +
    +
    + + +
    +

    + “ +

    +

    + {testimonials[currentIndex].text.replace( + /\${projectName}/g, + projectName, + )} +

    +

    + {testimonials[currentIndex].company} +

    +

    + - {testimonials[currentIndex].user_name} +

    +
    + + +
    +
    + ); +}; + +export default HorizontalCarousel; diff --git a/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/MultiCardDisplay.tsx b/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/MultiCardDisplay.tsx new file mode 100644 index 0000000..45655d1 --- /dev/null +++ b/frontend/src/components/WebPageComponents/TestimonialsComponent/designs/MultiCardDisplay.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import BaseButton from '../../../BaseButton'; + +const MultiCardDisplay = ({ + projectName, + testimonials, + getVisibleTestimonials, + handlePrev, + handleNext, + textSecondary, + corners, + mainText, + shadow, +}) => { + return ( +
    +
    +

    + {mainText} +

    +
    + + +
    +
    + +
    + {getVisibleTestimonials().map((testimonial, index) => ( +
    +
    +

    + Efficient Collaborating +

    +

    + {testimonial.text.replace(/\${projectName}/g, projectName)} +

    +
    +
    +

    + {testimonial.company} +

    +

    + - {testimonial.user_name} +

    +
    +
    + ))} +
    +
    + ); +}; + +export default MultiCardDisplay; diff --git a/frontend/src/components/WebPageComponents/TestimonialsComponent/index.tsx b/frontend/src/components/WebPageComponents/TestimonialsComponent/index.tsx new file mode 100644 index 0000000..5ba7492 --- /dev/null +++ b/frontend/src/components/WebPageComponents/TestimonialsComponent/index.tsx @@ -0,0 +1,112 @@ +// TestimonialsSection.js +import React, { useEffect, useState } from 'react'; +import HorizontalCarousel from './designs/HorizontalCarousel'; +import MultiCardDisplay from './designs/MultiCardDisplay'; +import { useAppSelector } from '../../../stores/hooks'; +import { TestimonialsDesigns } from '../designs'; + +export default function TestimonialsSection({ + projectName, + mainText, + testimonials, + design, +}) { + const textSecondary = useAppSelector((state) => state.style.textSecondary); + const corners = useAppSelector((state) => state.style.corners); + const websiteSectionStyle = useAppSelector( + (state) => state.style.websiteSectionStyle, + ); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const shadow = useAppSelector((state) => state.style.shadow); + const [currentIndex, setCurrentIndex] = useState(0); + + const handlePrev = () => { + setCurrentIndex((prevIndex) => + prevIndex === 0 ? testimonials.length - 1 : prevIndex - 1, + ); + }; + + const handleNext = () => { + setCurrentIndex((prevIndex) => + prevIndex === testimonials.length - 1 ? 0 : prevIndex + 1, + ); + }; + + const getVisibleTestimonials = () => { + const visibleTestimonials = []; + for (let i = 0; i < 3; i++) { + visibleTestimonials.push( + testimonials[(currentIndex + i) % testimonials.length], + ); + } + return visibleTestimonials; + }; + + const renderDesign = () => { + switch (design) { + case TestimonialsDesigns.HORIZONTAL_CAROUSEL: + case TestimonialsDesigns.HORIZONTAL_CAROUSEL_WITH_BG: + case TestimonialsDesigns.HORIZONTAL_CAROUSEL_DIVERSITY: + return ( + + ); + + case TestimonialsDesigns.MULTI_CARD_DISPLAY: + return ( + + ); + + default: + return ( + + ); + } + }; + + return ( +
    + {renderDesign()} +
    + ); +} diff --git a/frontend/src/components/WebPageComponents/designs.ts b/frontend/src/components/WebPageComponents/designs.ts new file mode 100644 index 0000000..3b3e061 --- /dev/null +++ b/frontend/src/components/WebPageComponents/designs.ts @@ -0,0 +1,77 @@ +export enum GalleryPortfolioDesigns { + DEFAULT_DESIGN, + HORIZONTAL_WITH_BUTTONS, + OVERLAPPING_CENTRAL_IMAGE, +} + +export enum HeroDesigns { + DEFAULT_DESIGN, + IMAGE_LEFT, + IMAGE_RIGHT, + IMAGE_BG, + TEXT_CENTER, +} + +export enum ContactFormDesigns { + DEFAULT_DESIGN, + SIMPLE_CLEAN, + SIMPLE_CLEAN_DIVERSITY, + HIGHLIGHTED, + HIGHLIGHTED_DIVERSITY, + WITH_IMAGE, +} + +export enum FaqDesigns { + DEFAULT_DESIGN, + ACCORDION, + SPLIT_LIST, + SPLIT_LIST_DIVERSITY, + TWO_COLUMN, +} + +export enum FeaturesDesigns { + DEFAULT_DESIGN, + CARDS_GRID_WITH_ICONS, + CARDS_GRID_WITH_ICONS_DIVERSITY, + ICONS_TOP, + LARGE_NUMBERS, + ICONS_WITH_IMAGE, +} + +export enum TestimonialsDesigns { + DEFAULT_DESIGN, + HORIZONTAL_CAROUSEL, + HORIZONTAL_CAROUSEL_WITH_BG, + HORIZONTAL_CAROUSEL_DIVERSITY, + MULTI_CARD_DISPLAY, +} + +export enum AboutUsDesigns { + DEFAULT_DESIGN, + IMAGE_LEFT, + IMAGE_RIGHT, +} + +export enum PricingDesigns { + DEFAULT_DESIGN, +} + +export enum HeaderStyle { + PAGES_RIGHT, + PAGES_LEFT, +} + +export enum FooterStyle { + WITH_PAGES, + WITH_PROJECT_NAME, +} + +export enum HeaderDesigns { + DESIGN_DIVERSITY, + DEFAULT_DESIGN, +} + +export enum FooterDesigns { + DESIGN_DIVERSITY, + DEFAULT_DESIGN, +} diff --git a/frontend/src/components/WidgetCreator/RoleSelect.tsx b/frontend/src/components/WidgetCreator/RoleSelect.tsx new file mode 100644 index 0000000..c2da317 --- /dev/null +++ b/frontend/src/components/WidgetCreator/RoleSelect.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useId, useState } from 'react'; +import { AsyncPaginate } from 'react-select-async-paginate'; +import axios from 'axios'; + +export const RoleSelect = ({ + options, + field, + form, + itemRef, + disabled, + currentUser, +}) => { + const [value, setValue] = useState(null); + const PAGE_SIZE = 100; + + React.useEffect(() => { + if (currentUser.app_role.id) { + setValue({ + value: currentUser.app_role.id, + label: currentUser.app_role.name, + }); + } + }, [currentUser]); + + useEffect(() => { + if (options?.value && options?.label) { + setValue({ value: options.value, label: options.label }); + } + }, [options?.id, field?.value?.id]); + + const mapResponseToValuesAndLabels = (data) => ({ + value: data.id, + label: data.label, + }); + const handleChange = (option) => { + form.setFieldValue(field.name, option); + setValue(option); + }; + + async function callApi(inputValue: string, loadedOptions: any[]) { + const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${ + loadedOptions.length + }${inputValue ? `&query=${inputValue}` : ''}`; + const { data } = await axios(path); + return { + options: data.map(mapResponseToValuesAndLabels), + hasMore: data.length === PAGE_SIZE, + }; + } + return ( + 'px-1 py-2', + }} + classNamePrefix={'react-select'} + instanceId={useId()} + value={value} + debounceTimeout={1000} + loadOptions={callApi} + onChange={handleChange} + defaultOptions + isDisabled={disabled} + /> + ); +}; diff --git a/frontend/src/components/WidgetCreator/WidgetCreator.tsx b/frontend/src/components/WidgetCreator/WidgetCreator.tsx new file mode 100644 index 0000000..030ecd0 --- /dev/null +++ b/frontend/src/components/WidgetCreator/WidgetCreator.tsx @@ -0,0 +1,149 @@ +import CardBox from '../CardBox'; +import { mdiCog } from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import { ToastContainer, toast } from 'react-toastify'; +import FormField from '../FormField'; +import React from 'react'; +import { + aiPrompt, + setErrorNotification, + resetNotify, +} from '../../stores/openAiSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; + +import { fetchWidgets } from '../../stores/roles/rolesSlice'; + +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import { RoleSelect } from './RoleSelect'; + +export const WidgetCreator = ({ + currentUser, + isFetchingQuery, + setWidgetsRole, + widgetsRole, +}) => { + const dispatch = useAppDispatch(); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const { notify: openAiNotify } = useAppSelector((state) => state.openAi); + + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + React.useEffect(() => { + if (openAiNotify.showNotification) { + notify(openAiNotify.typeNotification, openAiNotify.textNotification); + dispatch(resetNotify()); + } + }, [openAiNotify.showNotification]); + + const openModal = (): void => { + setIsModalOpen(true); + }; + + const handleCloseModal = (value = {}) => { + setWidgetsRole(value); + setIsModalOpen(false); + }; + + const getWidgets = async () => { + await dispatch(fetchWidgets(widgetsRole?.role?.value || '')); + }; + + const smartSearch = async ( + values: { description: string }, + resetForm: any, + ) => { + const description = values.description; + const projectId = '29737'; + + const payload = { + roleId: widgetsRole?.role?.value, + description, + projectId, + userId: currentUser?.id, + }; + const { payload: responcePayload, error }: any = await dispatch( + aiPrompt(payload), + ); + + await getWidgets().then(); + + resetForm({ values: { description: '' } }); + if (responcePayload.data?.error || error) { + const errorMessage = + responcePayload.data?.error?.message || error?.message; + await dispatch( + setErrorNotification(errorMessage || 'Error with widget creation'), + ); + } + }; + + return ( + <> + + + smartSearch(values, resetForm)} + > +
    + + + +
    +
    +
    + handleCloseModal(values)} + > + {({ submitForm }) => ( + setIsModalOpen(false)} + > +

    What role are we showing and creating widgets for?

    + +
    + + + +
    +
    + )} +
    + + + ); +}; diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..b45c575 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,22 @@ +export const hostApi = + process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + ? 'http://localhost' + : ''; +export const portApi = + process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + ? 8080 + : ''; +export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`; + +export const localStorageDarkModeKey = 'darkMode'; + +export const localStorageStyleKey = 'style'; + +export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'; + +export const appTitle = 'created by Flatlogic generator!'; + +export const getPageTitle = (currentPageTitle: string) => + `${currentPageTitle} — ${appTitle}`; + +export const tinyKey = 'cnslp6h943xbg36t2tf2xglmrxiw5b7tatycf3kir7n2j7eh'; diff --git a/frontend/src/css/_app.css b/frontend/src/css/_app.css new file mode 100644 index 0000000..4179b23 --- /dev/null +++ b/frontend/src/css/_app.css @@ -0,0 +1,34 @@ +html { + @apply h-full; +} + +body { + @apply pt-14 xl:pl-60 h-full; +} + +#app { + @apply w-screen transition-position lg:w-auto h-full flex flex-col; +} + +.dropdown { + @apply cursor-pointer; +} + +li.stack-item:not(:last-child):after { + content: '/'; + @apply inline-block pl-2; +} + +.m-clipped, +.m-clipped body { + @apply overflow-hidden lg:overflow-visible; +} + +.full-screen body { + @apply p-0; +} + +.main-navbar, +.app-sidebar-brand { + box-shadow: 0px -1px 40px rgba(112, 144, 176, 0.2); +} diff --git a/frontend/src/css/_calendar.css b/frontend/src/css/_calendar.css new file mode 100644 index 0000000..c191f07 --- /dev/null +++ b/frontend/src/css/_calendar.css @@ -0,0 +1,62 @@ +.rbc-event { + @apply bg-pastelEmeraldTheme-buttonColor !important; +} + +.rbc-show-more { + @apply text-primaryText dark:text-white bg-transparent !important; +} + +.rbc-btn-group button { + @apply text-primaryText border-gray-400 dark:text-white !important; +} + +.rbc-btn-group button:hover { + @apply bg-pastelEmeraldTheme-buttonColor text-white dark:bg-dark-700 !important; +} + +.rbc-btn-group button.rbc-active { + @apply bg-pastelEmeraldTheme-buttonColor text-white dark:bg-blue-600 !important; +} + +.rbc-btn-group button:focus { + @apply bg-pastelEmeraldTheme-buttonColor dark:bg-blue-600 !important; +} + +.rbc-day-bg.rbc-off-range-bg { + @apply bg-pastelEmeraldTheme-mainBG dark:bg-dark-800 !important; +} +.rbc-current-time-indicator { + @apply h-1 !important; +} +.rbc-today { + @apply dark:bg-dark-600/40 !important; +} + +.rbc-day-bg.rbc-selected-cell { + @apply dark:bg-dark-500 !important; +} + +.rbc-day-bg, +.rbc-timeslot-group, +.rbc-time-header, +.rbc-event, +.rbc-btn-group, +.rbc-month-view, +.rbc-month-view .rbc-header, +.rbc-month-row, +.rbc-date-cell { + @apply border-gray-400 !important; +} + +.rbc-timeslot-group { + @apply border-gray-400 !important; +} + +.rbc-time-header, +.rbc-month-row { + @apply border-gray-400 !important; +} + +.rbc-date-cell { + @apply border-gray-400 !important; +} diff --git a/frontend/src/css/_checkbox-radio-switch.css b/frontend/src/css/_checkbox-radio-switch.css new file mode 100644 index 0000000..ad1530f --- /dev/null +++ b/frontend/src/css/_checkbox-radio-switch.css @@ -0,0 +1,73 @@ +@layer components { + .checkbox, + .radio, + .switch { + @apply inline-flex items-center cursor-pointer relative; + } + + .checkbox input[type='checkbox'], + .radio input[type='radio'], + .switch input[type='checkbox'] { + @apply absolute left-0 opacity-0 -z-1; + } + + .checkbox input[type='checkbox'] + .check, + .radio input[type='radio'] + .check, + .switch input[type='checkbox'] + .check { + @apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + @apply ring ring-pastelEmeraldTheme-800; + } + + .checkbox input[type='checkbox'] + .check, + .radio input[type='radio'] + .check { + @apply block w-5 h-5; + } + + .checkbox input[type='checkbox'] + .check { + @apply rounded; + } + + .switch input[type='checkbox'] + .check { + @apply flex items-center shrink-0 w-12 h-6 p-0.5 bg-gray-200; + } + + .radio input[type='radio'] + .check, + .switch input[type='checkbox'] + .check, + .switch input[type='checkbox'] + .check:before { + @apply rounded-full; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply bg-no-repeat bg-center border-4; + } + + .checkbox input[type='checkbox']:checked + .check { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E"); + } + + .radio input[type='radio']:checked + .check { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E"); + } + + .switch input[type='checkbox']:checked + .check, + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply bg-pastelEmeraldTheme-buttonColor border-pastelEmeraldTheme-buttonColor; + } + + .switch input[type='checkbox'] + .check:before { + content: ''; + @apply block w-5 h-5 bg-white border border-gray-700; + } + + .switch input[type='checkbox']:checked + .check:before { + transform: translate3d(110%, 0, 0); + @apply border-blue-600; + } +} diff --git a/frontend/src/css/_helper.css b/frontend/src/css/_helper.css new file mode 100644 index 0000000..9425a7c --- /dev/null +++ b/frontend/src/css/_helper.css @@ -0,0 +1,24 @@ +.helper-container { + right: 0; + top: 70px; + transform: translateX(100%); + + .tab { + top: 0; + left: 0; + transform: translateX(-100%); + } + + .tab:hover { + @apply bg-gray-900 cursor-pointer; + } +} + +.helper-container.open { + transform: translateX(0); +} + +.react-datepicker-wrapper, +.react-datepicker-popper { + z-index: 10 !important; +} diff --git a/frontend/src/css/_progress.css b/frontend/src/css/_progress.css new file mode 100644 index 0000000..d419f78 --- /dev/null +++ b/frontend/src/css/_progress.css @@ -0,0 +1,21 @@ +@layer base { + progress { + @apply h-3 rounded-full overflow-hidden; + } + + progress::-webkit-progress-bar { + @apply bg-blue-200; + } + + progress::-webkit-progress-value { + @apply bg-blue-500; + } + + progress::-moz-progress-bar { + @apply bg-blue-500; + } + + progress::-ms-fill { + @apply bg-blue-500 border-0; + } +} diff --git a/frontend/src/css/_rich-text.css b/frontend/src/css/_rich-text.css new file mode 100644 index 0000000..db52cea --- /dev/null +++ b/frontend/src/css/_rich-text.css @@ -0,0 +1,63 @@ +/* Editor background */ +.tox .tox-editor-container { + @apply bg-pastelEmeraldTheme-mainBG dark:bg-dark-800 !important; +} + +/* Toolbar background */ +.tox .tox-toolbar, +.tox .tox-editor-header, +.tox .tox-menu, +.tox .tox-menubar, +.tox .tox-toolbar__primary { + @apply bg-pastelEmeraldTheme-cardColor dark:bg-dark-800 !important; +} + +.tox .tox-collection--toolbar .tox-collection__item--active, +.tox .tox-collection--toolbar .tox-collection__item--enabled { + @apply bg-pastelEmeraldTheme-diversityMain dark:bg-dark-600 !important; +} + +/* Toolbar button color */ +.tox .tox-toolbar__group button, +.tox .tox-mbtn { + @apply text-pastelEmeraldTheme-text dark:text-white !important; +} + +.tox .tox-tbtn svg { + @apply fill-pastelEmeraldTheme-text dark:text-white !important; +} + +/* Toolbar button hover/active state */ +.tox .tox-tbtn--enabled, +.tox .tox-tbtn--enabled:hover, +.tox .tox-tbtn:hover, +.tox .tox-mbtn:hover { + @apply bg-pastelEmeraldTheme-diversityMain dark:bg-dark-600 !important; +} + +/* Status bar styles */ +.tox .tox-statusbar, +.tox { + @apply bg-pastelEmeraldTheme-mainBG dark:bg-dark-800 text-pastelEmeraldTheme-text dark:text-white !important; +} + +.tox .tox-statusbar a, +.tox .tox-statusbar__path-item, +.tox .tox-statusbar__wordcount { + @apply text-pastelEmeraldTheme-text dark:text-white !important; +} + +/* Dropdown menu item text */ +.tox .tox-collection__item { + @apply text-pastelEmeraldTheme-text dark:text-white !important; +} + +/* Editor content area */ +.tox.tox-tinymce { + @apply border border-pastelEmeraldTheme-outsideCardColor dark:border-dark-700 text-pastelEmeraldTheme-text dark:text-white !important; +} + +/* Change the content area background color */ +.tox .tox-edit-area__iframe { + @apply bg-pastelEmeraldTheme-mainBG dark:bg-dark-800 text-pastelEmeraldTheme-text dark:text-white !important; +} diff --git a/frontend/src/css/_scrollbars.css b/frontend/src/css/_scrollbars.css new file mode 100644 index 0000000..11445a9 --- /dev/null +++ b/frontend/src/css/_scrollbars.css @@ -0,0 +1,41 @@ +@layer base { + html { + scrollbar-width: thin; + scrollbar-color: rgb(156, 163, 175) rgb(249, 250, 251); + } + + body::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + body::-webkit-scrollbar-track { + @apply bg-gray-50; + } + + body::-webkit-scrollbar-thumb { + @apply bg-gray-400 rounded; + } + + body::-webkit-scrollbar-thumb:hover { + @apply bg-gray-500; + } +} + +@layer utilities { + .dark-scrollbars-compat { + scrollbar-color: rgb(71, 85, 105) rgb(30, 41, 59); + } + + .dark-scrollbars::-webkit-scrollbar-track { + @apply bg-slate-800; + } + + .dark-scrollbars::-webkit-scrollbar-thumb { + @apply bg-slate-600; + } + + .dark-scrollbars::-webkit-scrollbar-thumb:hover { + @apply bg-slate-500; + } +} diff --git a/frontend/src/css/_select-dropdown.css b/frontend/src/css/_select-dropdown.css new file mode 100644 index 0000000..67feff8 --- /dev/null +++ b/frontend/src/css/_select-dropdown.css @@ -0,0 +1,32 @@ +.react-select__control { + @apply bg-pastelEmeraldTheme-cardColor rounded-lg dark:bg-dark-800 dark:border-dark-700 !important; +} + +.react-select__single-value { + @apply text-primaryText dark:text-white !important; +} + +.react-select__menu { + @apply bg-pastelEmeraldTheme-cardColor dark:border-dark-700; +} + +.react-select__menu-list { + @apply bg-pastelEmeraldTheme-cardColor dark:bg-dark-800 dark:border-dark-700 dark:rounded !important; +} + +.react-select__option { + @apply cursor-pointer hover:bg-gray-200 hover:bg-pastelEmeraldTheme-cardColor dark:hover:bg-dark-700 !important; +} + +.react-select__option--is-focused { + @apply bg-pastelEmeraldTheme-cardColor text-primaryText dark:bg-dark-800 dark:text-white hover:dark:bg-dark-700 hover:dark:text-white !important; +} + +.react-select__option--is-selected, +.react-select__option--is-selected:hover { + @apply bg-pastelEmeraldTheme-cardColor dark:bg-dark-600 !important; +} + +.react-select__multi-value__remove { + @apply text-pastelEmeraldTheme-cardColor dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important; +} diff --git a/frontend/src/css/_table.css b/frontend/src/css/_table.css new file mode 100644 index 0000000..a258b44 --- /dev/null +++ b/frontend/src/css/_table.css @@ -0,0 +1,123 @@ +@layer base { + table { + @apply w-full; + } + + thead { + @apply hidden lg:table-header-group; + } + + tr { + @apply max-w-full block relative border-b-4 border-gray-100 + lg:table-row lg:border-b-0 dark:border-slate-800; + } + + tr:last-child { + @apply border-b-0; + } + + td:not(:first-child) { + @apply lg:border-l lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-gray-100 lg:dark:border-slate-700; + } + + th { + @apply lg:text-left lg:p-3 border-b; + } + + th.sortable { + cursor: pointer; + } + + th.sortable:hover:after { + transition: all 1s; + position: absolute; + + content: '↕'; + + margin-left: 1rem; + } + + th.sortable.asc:hover:after { + content: '↑'; + } + th.sortable.desc:hover:after { + content: '↓'; + } + + td { + @apply flex justify-between text-right py-3 px-4 align-top border-b border-gray-100 + lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white; + } + + td:last-child { + @apply border-b-0; + } + + tbody tr, + tbody tr:nth-child(odd) { + @apply lg:hover:bg-pastelEmeraldTheme-mainBG; + } + + tbody tr:nth-child(even) { + @apply lg:bg-pastelEmeraldTheme-mainBG dark:bg-pavitra-300/70; + } + + td:before { + content: attr(data-label); + @apply font-semibold pr-3 text-left lg:hidden; + } + + tbody tr td { + @apply text-sm font-normal text-primaryText dark:text-white; + } + + .datagrid--table, + .MuiDataGrid-root { + @apply rounded border-none !important; + + @apply font-sans text-primaryText !important; + } + + .datagrid--header { + @apply uppercase !important; + } + + .datagrid--header, + .datagrid--header .MuiIconButton-root, + .datagrid--cell, + .datagrid--cell .MuiIconButton-root { + @apply text-primaryText dark:text-white; + } + + .datagrid--cell .MuiDataGrid-booleanCell { + @apply text-primaryText dark:text-white !important; + } + + .datagrid--cell .MuiIconButton-root:hover { + @apply text-primaryText dark:text-white dark:bg-dark-700; + } + + .datagrid--row { + @apply even:bg-pastelEmeraldTheme-mainBG dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important; + } + + .datagrid--row:hover { + @apply bg-pastelEmeraldTheme-mainBG !important; + } + + .datagrid--table .MuiTablePagination-root { + @apply text-primaryText dark:text-white; + } + + .datagrid--table .MuiTablePagination-root .MuiButtonBase-root:disabled { + @apply text-primaryText dark:text-dark-700; + } + + .datagrid--table .MuiTablePagination-root .MuiButtonBase-root:hover { + @apply dark:bg-dark-700; + } + + .MuiButton-colorInherit { + @apply text-pastelEmeraldTheme-iconsColor dark:text-dark-700 !important; + } +} diff --git a/frontend/src/css/_theme.css b/frontend/src/css/_theme.css new file mode 100644 index 0000000..765b8d3 --- /dev/null +++ b/frontend/src/css/_theme.css @@ -0,0 +1,105 @@ +.theme-pink { + .app-sidebar { + @apply bg-pavitra-900 text-white; + + .menu-title, + .menu-item-icon, + .menu-item-link { + @apply text-white; + } + } + + .app-sidebar-brand { + @apply bg-white; + } + + .bg-blue-600 { + @apply bg-pavitra-800; + } + + .border-blue-700 { + @apply border-pink-700; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply border-pavitra-800; + } + + .helper-container .tab { + @apply bg-pavitra-900; + } + + .focus\:ring:focus { + --tw-ring-color: #14142a; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + --tw-ring-color: #14142a; + } +} + +.theme-green { + .app-sidebar { + @apply bg-pavitra-800 text-white; + + .menu-title, + .menu-item-icon, + .menu-item-link { + @apply text-white; + } + } + + .app-sidebar-brand { + @apply bg-white; + } + + .bg-blue-600 { + @apply bg-pavitra-800; + } + + .border-blue-700 { + @apply bg-pavitra-700; + } + + .hover\:bg-blue-700:hover { + @apply bg-pavitra-700; + } + + .text-blue-600 { + @apply text-pavitra-900; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply border-pavitra-800; + } + + .helper-container .tab { + @apply bg-pavitra-700; + } + + .focus\:ring:focus { + --tw-ring-color: #4e4b66; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + --tw-ring-color: #4e4b66; + } + + .text-blue-500 { + @apply text-pavitra-800; + } + + .hover\:text-blue-600:hover { + @apply text-pavitra-800; + } + + .active\:text-blue-700:active { + @apply text-pavitra-800; + } +} diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css new file mode 100644 index 0000000..61105fd --- /dev/null +++ b/frontend/src/css/main.css @@ -0,0 +1,62 @@ +@import 'tailwind/_base.css'; +@import 'tailwind/_components.css'; +@import 'tailwind/_utilities.css'; +@import 'intro.js/introjs.css'; +@import '_checkbox-radio-switch.css'; +@import '_progress.css'; +@import '_scrollbars.css'; +@import '_table.css'; +@import '_helper.css'; +@import '_calendar.css'; +@import '_select-dropdown.css'; +@import '_theme.css'; +@import '_rich-text.css'; + +@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@300;400;600;700&display=swap'); + +body { + @apply font-sans text-primaryText !important; +} +.MuiList-root, +.MuiListItemIcon-root { + @apply text-pastelEmeraldTheme-buttonColor !important; +} +.MuiCheckbox-colorPrimary { + @apply text-pastelEmeraldTheme-buttonColor !important; +} +.MuiDataGrid-row.Mui-selected { + @apply bg-pastelEmeraldTheme-mainBG !important; +} +.MuiDataGrid-withBorderColor { + @apply border-pastelEmeraldTheme-mainBG !important; +} +.MuiDataGrid-cell:focus { + @apply outline-pastelEmeraldTheme-buttonColor !important; +} + +.introjs-tooltip { + @apply min-w-[400px] max-w-[480px] p-2 !important; +} + +.introjs-tooltipReferenceLayer * { + @apply font-sans text-base text-gray-900; + + font-family: 'Nunito Sans', sans-serif !important; +} + +.good-img { + @apply -mt-96 !important; +} +.end-img { + @apply -mt-72 !important; +} +.introjs-button { + @apply bg-pastelEmeraldTheme-buttonColor text-white !important; + text-shadow: none !important; +} +.introjs-bullets ul li a.active { + @apply bg-pastelEmeraldTheme-iconsColor !important; +} +.introjs-prevbutton { + @apply bg-transparent border border-pastelEmeraldTheme-buttonColor text-pastelEmeraldTheme-buttonColor !important; +} diff --git a/frontend/src/css/tailwind/_base.css b/frontend/src/css/tailwind/_base.css new file mode 100644 index 0000000..2f02db5 --- /dev/null +++ b/frontend/src/css/tailwind/_base.css @@ -0,0 +1 @@ +@tailwind base; diff --git a/frontend/src/css/tailwind/_components.css b/frontend/src/css/tailwind/_components.css new file mode 100644 index 0000000..020aaba --- /dev/null +++ b/frontend/src/css/tailwind/_components.css @@ -0,0 +1 @@ +@tailwind components; diff --git a/frontend/src/css/tailwind/_utilities.css b/frontend/src/css/tailwind/_utilities.css new file mode 100644 index 0000000..65dd5f6 --- /dev/null +++ b/frontend/src/css/tailwind/_utilities.css @@ -0,0 +1 @@ +@tailwind utilities; diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js new file mode 100644 index 0000000..9e3bfc4 --- /dev/null +++ b/frontend/src/helpers/dataFormatter.js @@ -0,0 +1,155 @@ +import dayjs from 'dayjs'; +import _ from 'lodash'; + +export default { + filesFormatter(arr) { + if (!arr || !arr.length) return []; + return arr.map((item) => item); + }, + imageFormatter(arr) { + if (!arr || !arr.length) return []; + return arr.map((item) => ({ + publicUrl: item.publicUrl || '', + })); + }, + oneImageFormatter(arr) { + if (!arr || !arr.length) return ''; + return arr[0].publicUrl || ''; + }, + dateFormatter(date) { + if (!date) return ''; + return dayjs(date).format('YYYY-MM-DD'); + }, + dateTimeFormatter(date) { + if (!date) return ''; + return dayjs(date).format('YYYY-MM-DD HH:mm'); + }, + booleanFormatter(val) { + return val ? 'Yes' : 'No'; + }, + dataGridEditFormatter(obj) { + return _.transform(obj, (result, value, key) => { + if (_.isArray(value)) { + result[key] = _.map(value, 'id'); + } else if (_.isObject(value)) { + result[key] = value.id; + } else { + result[key] = value; + } + }); + }, + + usersManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.firstName); + }, + usersOneListFormatter(val) { + if (!val) return ''; + return val.firstName; + }, + usersManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.firstName }; + }); + }, + usersOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.firstName, id: val.id }; + }, + + clientsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.mood); + }, + clientsOneListFormatter(val) { + if (!val) return ''; + return val.mood; + }, + clientsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.mood }; + }); + }, + clientsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.mood, id: val.id }; + }, + + sessionsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.start_time); + }, + sessionsOneListFormatter(val) { + if (!val) return ''; + return val.start_time; + }, + sessionsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.start_time }; + }); + }, + sessionsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.start_time, id: val.id }; + }, + + therapistsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.specialization); + }, + therapistsOneListFormatter(val) { + if (!val) return ''; + return val.specialization; + }, + therapistsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.specialization }; + }); + }, + therapistsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.specialization, id: val.id }; + }, + + rolesManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + rolesOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + rolesManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + rolesOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, + + permissionsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + permissionsOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + permissionsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + permissionsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, +}; diff --git a/frontend/src/helpers/fileSaver.ts b/frontend/src/helpers/fileSaver.ts new file mode 100644 index 0000000..242b540 --- /dev/null +++ b/frontend/src/helpers/fileSaver.ts @@ -0,0 +1,6 @@ +import { saveAs } from 'file-saver'; + +export const saveFile = (e, url: string, name: string) => { + e.stopPropagation(); + saveAs(url, name); +}; diff --git a/frontend/src/helpers/humanize.ts b/frontend/src/helpers/humanize.ts new file mode 100644 index 0000000..61b6407 --- /dev/null +++ b/frontend/src/helpers/humanize.ts @@ -0,0 +1,12 @@ +export function humanize(str: string) { + if (!str) { + return ''; + } + return str + .toString() + .replace(/^[\s_]+|[\s_]+$/g, '') + .replace(/[_\s]+/g, ' ') + .replace(/^[a-z]/, function (m) { + return m.toUpperCase(); + }); +} diff --git a/frontend/src/helpers/notifyStateHandler.ts b/frontend/src/helpers/notifyStateHandler.ts new file mode 100644 index 0000000..c13987b --- /dev/null +++ b/frontend/src/helpers/notifyStateHandler.ts @@ -0,0 +1,30 @@ +export const resetNotify = (state) => { + state.notify.showNotification = false; + state.notify.typeNotification = ''; + state.notify.textNotification = ''; +}; +export const rejectNotify = (state, action) => { + if (typeof action.payload === 'string') { + state.notify.textNotification = action.payload; + } else if (typeof action === 'object') { + const obj = { ...action.payload?.errors }; + delete obj['_errors']; + + let msg = ''; + + for (const key in obj) { + msg += `${key}: ${obj[key]['_errors']}; \n `; + } + + state.notify.textNotification = msg; + } else { + state.notify.textNotification = ''; + } + state.notify.typeNotification = 'error'; + state.notify.showNotification = true; +}; +export const fulfilledNotify = (state, msg) => { + state.notify.textNotification = msg; + state.notify.typeNotification = 'success'; + state.notify.showNotification = true; +}; diff --git a/frontend/src/helpers/pexels.ts b/frontend/src/helpers/pexels.ts new file mode 100644 index 0000000..524a1b2 --- /dev/null +++ b/frontend/src/helpers/pexels.ts @@ -0,0 +1,75 @@ +import axios from 'axios'; + +export async function getPexelsImage() { + try { + const response = await axios.get(`/pexels/image`); + return response.data; + } catch (error) { + console.error('Error fetching image:', error); + return null; + } +} + +export async function getPexelsVideo() { + try { + const response = await axios.get(`/pexels/video`); + return response.data; + } catch (error) { + console.error('Error fetching video:', error); + return null; + } +} + +let localStorageLock = false; + +export async function getMultiplePexelsImages( + queries = ['home', 'apple', 'pizza', 'mountains', 'cat'], +) { + const normalizeQuery = (query) => + query.trim().toLowerCase().replace(/\s+/g, ''); + + while (localStorageLock) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + localStorageLock = true; + + const cachedImages = + JSON.parse(localStorage.getItem('pexelsImagesCache')) || {}; + + const isImageCached = (query) => { + const normalizedQuery = normalizeQuery(query); + const cached = cachedImages[normalizedQuery]; + const isCached = + cached && cached.src && cached.photographer && cached.photographer_url; + return isCached; + }; + + const missingQueries = queries.filter((query) => !isImageCached(query)); + + if (missingQueries.length > 0) { + const queryString = missingQueries.join(','); + + try { + const response = await axios.get(`/pexels/multiple-images`, { + params: { queries: queryString }, + }); + + missingQueries.forEach((query, index) => { + const normalizedQuery = normalizeQuery(query); + if (!cachedImages[normalizedQuery]) { + cachedImages[normalizedQuery] = response.data[index]; + } + }); + + localStorage.setItem('pexelsImagesCache', JSON.stringify(cachedImages)); + } catch (error) { + console.error(error); + } + } + + const result = queries.map((query) => cachedImages[normalizeQuery(query)]); + + localStorageLock = false; + + return result; +} diff --git a/frontend/src/helpers/userPermissions.ts b/frontend/src/helpers/userPermissions.ts new file mode 100644 index 0000000..377546b --- /dev/null +++ b/frontend/src/helpers/userPermissions.ts @@ -0,0 +1,18 @@ +export function hasPermission(user, permission_name: string | string[]) { + if (!user?.app_role?.name) return false; + if (!permission_name) { + return true; + } + const permissions = new Set([ + ...(user?.custom_permissions ?? []).map((p) => p.name), + ...(user?.app_role_permissions ?? []).map((p) => p.name), + ]); + + if (typeof permission_name === 'string') { + return ( + permissions.has(permission_name) || user.app_role.name === 'Administrator' + ); + } else { + return permission_name.some((permission) => permissions.has(permission)); + } +} diff --git a/frontend/src/hooks/sampleData.ts b/frontend/src/hooks/sampleData.ts new file mode 100644 index 0000000..8c74ad5 --- /dev/null +++ b/frontend/src/hooks/sampleData.ts @@ -0,0 +1,22 @@ +import useSWR from 'swr'; +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export const useSampleClients = () => { + const { data, error } = useSWR('/data-sources/clients.json', fetcher); + + return { + clients: data?.data ?? [], + isLoading: !error && !data, + isError: error, + }; +}; + +export const useSampleTransactions = () => { + const { data, error } = useSWR('/data-sources/history.json', fetcher); + + return { + transactions: data?.data ?? [], + isLoading: !error && !data, + isError: error, + }; +}; diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts new file mode 100644 index 0000000..75048f3 --- /dev/null +++ b/frontend/src/interfaces/index.ts @@ -0,0 +1,122 @@ +export type UserPayloadObject = { + name: string; + email: string; + avatar: string; +}; + +export type MenuAsideItem = { + label: string; + icon?: string; + href?: string; + target?: string; + color?: ColorButtonKey; + isLogout?: boolean; + withDevider?: boolean; + menu?: MenuAsideItem[]; + permissions?: string | string[]; +}; + +export type MenuNavBarItem = { + label?: string; + icon?: string; + href?: string; + target?: string; + isDivider?: boolean; + isLogout?: boolean; + isDesktopNoLabel?: boolean; + isToggleLightDark?: boolean; + isCurrentUser?: boolean; + menu?: MenuNavBarItem[]; +}; + +export type ColorKey = + | 'white' + | 'light' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info'; + +export type ColorButtonKey = + | 'white' + | 'whiteDark' + | 'lightDark' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'void'; + +export type BgKey = 'purplePink' | 'pinkRed' | 'violet'; + +export type TrendType = + | 'up' + | 'down' + | 'success' + | 'danger' + | 'warning' + | 'info'; + +export type TransactionType = 'withdraw' | 'deposit' | 'invoice' | 'payment'; + +export type Transaction = { + id: number; + amount: number; + account: string; + name: string; + date: string; + type: TransactionType; + business: string; +}; + +export type Client = { + id: number; + avatar: string; + login: string; + name: string; + city: string; + company: string; + firstName: string; + lastName: string; + phoneNumber: string; + email: string; + progress: number; + role: string; + disabled: boolean; + created: string; + created_mm_dd_yyyy: string; +}; + +export interface User { + id: string; + firstName: string; + lastName?: any; + phoneNumber?: any; + email: string; + role: string; + disabled: boolean; + password: string; + emailVerified: boolean; + emailVerificationToken?: any; + emailVerificationTokenExpiresAt?: any; + passwordResetToken?: any; + passwordResetTokenExpiresAt?: any; + provider: string; + importHash?: any; + createdAt: Date; + updatedAt: Date; + deletedAt?: any; + createdById?: any; + updatedById?: any; + avatar: any[]; + notes: any[]; +} + +export type StyleKey = 'white' | 'basic'; + +export type UserForm = { + name: string; + email: string; +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx new file mode 100644 index 0000000..7d616a3 --- /dev/null +++ b/frontend/src/layouts/Authenticated.tsx @@ -0,0 +1,132 @@ +import React, { ReactNode, useEffect } from 'react'; +import { useState } from 'react'; +import jwt from 'jsonwebtoken'; +import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'; +import menuAside from '../menuAside'; +import menuNavBar from '../menuNavBar'; +import BaseIcon from '../components/BaseIcon'; +import NavBar from '../components/NavBar'; +import NavBarItemPlain from '../components/NavBarItemPlain'; +import AsideMenu from '../components/AsideMenu'; +import FooterBar from '../components/FooterBar'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import Search from '../components/Search'; +import { useRouter } from 'next/router'; +import { findMe, logoutUser } from '../stores/authSlice'; + +import { hasPermission } from '../helpers/userPermissions'; + +type Props = { + children: ReactNode; + + permission?: string; +}; + +export default function LayoutAuthenticated({ + children, + + permission, +}: Props) { + const dispatch = useAppDispatch(); + const router = useRouter(); + const { token, currentUser } = useAppSelector((state) => state.auth); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + let localToken; + if (typeof window !== 'undefined') { + // Perform localStorage action + localToken = localStorage.getItem('token'); + } + + const isTokenValid = () => { + const token = localStorage.getItem('token'); + if (!token) return; + const date = new Date().getTime() / 1000; + const data = jwt.decode(token); + if (!data) return; + return date < data.exp; + }; + + useEffect(() => { + dispatch(findMe()); + if (!isTokenValid()) { + dispatch(logoutUser()); + router.push('/login'); + } + }, [token, localToken]); + + useEffect(() => { + if (!permission || !currentUser) return; + + if (!hasPermission(currentUser, permission)) router.push('/error'); + }, [currentUser, permission]); + + const darkMode = useAppSelector((state) => state.style.darkMode); + + const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false); + const [isAsideLgActive, setIsAsideLgActive] = useState(false); + + useEffect(() => { + const handleRouteChangeStart = () => { + setIsAsideMobileExpanded(false); + setIsAsideLgActive(false); + }; + + router.events.on('routeChangeStart', handleRouteChangeStart); + + // If the component is unmounted, unsubscribe + // from the event with the `off` method: + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart); + }; + }, [router.events, dispatch]); + + const layoutAsidePadding = 'xl:pl-60'; + + return ( +
    +
    + + setIsAsideMobileExpanded(!isAsideMobileExpanded)} + > + + + setIsAsideLgActive(true)} + > + + + + + + + setIsAsideLgActive(false)} + /> + {children} + Hand-crafted & Made with ❤️ +
    +
    + ); +} diff --git a/frontend/src/layouts/Guest.tsx b/frontend/src/layouts/Guest.tsx new file mode 100644 index 0000000..49ac1b0 --- /dev/null +++ b/frontend/src/layouts/Guest.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + children: ReactNode; +}; + +export default function LayoutGuest({ children }: Props) { + const darkMode = useAppSelector((state) => state.style.darkMode); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + + return ( +
    +
    + {children} +
    +
    + ); +} diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts new file mode 100644 index 0000000..d366916 --- /dev/null +++ b/frontend/src/menuAside.ts @@ -0,0 +1,110 @@ +import * as icon from '@mdi/js'; +import { MenuAsideItem } from './interfaces'; + +const menuAside: MenuAsideItem[] = [ + { + href: '/dashboard', + icon: icon.mdiViewDashboardOutline, + label: 'Dashboard', + }, + + { + href: '/users/users-list', + label: 'Users', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiAccountGroup ? icon.mdiAccountGroup : icon.mdiTable, + permissions: 'READ_USERS', + }, + { + href: '/administrators/administrators-list', + label: 'Administrators', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiLock ? icon.mdiLock : icon.mdiTable, + permissions: 'READ_ADMINISTRATORS', + }, + { + href: '/clients/clients-list', + label: 'Clients', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiAccountCircle ? icon.mdiAccountCircle : icon.mdiTable, + permissions: 'READ_CLIENTS', + }, + { + href: '/resources/resources-list', + label: 'Resources', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiBookOpenPageVariant + ? icon.mdiBookOpenPageVariant + : icon.mdiTable, + permissions: 'READ_RESOURCES', + }, + { + href: '/sessions/sessions-list', + label: 'Sessions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiCalendar ? icon.mdiCalendar : icon.mdiTable, + permissions: 'READ_SESSIONS', + }, + { + href: '/supervisors/supervisors-list', + label: 'Supervisors', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiAccountCircle ? icon.mdiAccountCircle : icon.mdiTable, + permissions: 'READ_SUPERVISORS', + }, + { + href: '/therapists/therapists-list', + label: 'Therapists', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiAccountCircle ? icon.mdiAccountCircle : icon.mdiTable, + permissions: 'READ_THERAPISTS', + }, + { + href: '/roles/roles-list', + label: 'Roles', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountVariantOutline + ? icon.mdiShieldAccountVariantOutline + : icon.mdiTable, + permissions: 'READ_ROLES', + }, + { + href: '/permissions/permissions-list', + label: 'Permissions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountOutline + ? icon.mdiShieldAccountOutline + : icon.mdiTable, + permissions: 'READ_PERMISSIONS', + }, + { + href: '/profile', + label: 'Profile', + icon: icon.mdiAccountCircle, + }, + + { + href: '/home', + label: 'Home page', + icon: icon.mdiHome, + withDevider: true, + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: icon.mdiFileCode, + permissions: 'READ_API_DOCS', + }, +]; + +export default menuAside; diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts new file mode 100644 index 0000000..f24b3b0 --- /dev/null +++ b/frontend/src/menuNavBar.ts @@ -0,0 +1,49 @@ +import { + mdiMenu, + mdiClockOutline, + mdiCloud, + mdiCrop, + mdiAccount, + mdiCogOutline, + mdiEmail, + mdiLogout, + mdiThemeLightDark, + mdiGithub, + mdiVuejs, +} from '@mdi/js'; +import { MenuNavBarItem } from './interfaces'; + +const menuNavBar: MenuNavBarItem[] = [ + { + isCurrentUser: true, + menu: [ + { + icon: mdiAccount, + label: 'My Profile', + href: '/profile', + }, + { + isDivider: true, + }, + { + icon: mdiLogout, + label: 'Log Out', + isLogout: true, + }, + ], + }, + { + icon: mdiThemeLightDark, + label: 'Light/Dark', + isDesktopNoLabel: true, + isToggleLightDark: true, + }, + { + icon: mdiLogout, + label: 'Log out', + isDesktopNoLabel: true, + isLogout: true, + }, +]; + +export default menuNavBar; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx new file mode 100644 index 0000000..4c8ace8 --- /dev/null +++ b/frontend/src/pages/_app.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import type { AppProps } from 'next/app'; +import type { ReactElement, ReactNode } from 'react'; +import type { NextPage } from 'next'; +import Head from 'next/head'; +import { store } from '../stores/store'; +import { Provider } from 'react-redux'; +import '../css/main.css'; +import axios from 'axios'; +import { baseURLApi } from '../config'; +import { useRouter } from 'next/router'; +import 'intro.js/introjs.css'; +import IntroGuide from '../components/IntroGuide'; +import { + appSteps, + landingSteps, + loginSteps, + usersSteps, + rolesSteps, +} from '../stores/introSteps'; + +export type NextPageWithLayout

    , IP = P> = NextPage< + P, + IP +> & { + getLayout?: (page: ReactElement) => ReactNode; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout; +}; + +function MyApp({ Component, pageProps }: AppPropsWithLayout) { + // Use the layout defined at the page level, if available + const getLayout = Component.getLayout || ((page) => page); + + if (typeof window !== 'undefined') { + // Perform localStorage action + console.log( + 'process.env.NEXT_PUBLIC_BACK_API', + process.env.NEXT_PUBLIC_BACK_API, + ); + axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API + ? process.env.NEXT_PUBLIC_BACK_API + : baseURLApi; + axios.defaults.headers.common['Content-Type'] = 'application/json'; + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; + } + } + + React.useEffect(() => { + if (typeof window !== 'undefined') { + const handleMessage = (event) => { + if (event.data === 'getLocation') { + event.source.postMessage( + { iframeLocation: window.location.pathname }, + event.origin, + ); + } + }; + + window.addEventListener('message', handleMessage); + + // Cleanup listener on unmount + return () => { + window.removeEventListener('message', handleMessage); + }; + } + }, []); + + const title = 'mental health wellbing app'; + + const description = 'mental health wellbing app generated by Flatlogic'; + + const url = 'https://flatlogic.com/'; + + const image = `https://flatlogic.com/logo.svg`; + + const imageWidth = '1920'; + + const imageHeight = '960'; + + const [stepsEnabled, setStepsEnabled] = React.useState(true); + const [stepName, setStepName] = React.useState(''); + const [steps, setSteps] = React.useState([]); + const router = useRouter(); + React.useEffect(() => { + const isCompleted = (stepKey: string) => { + return localStorage.getItem(`completed_${stepKey}`) === 'true'; + }; + if (router.pathname === '/login' && !isCompleted('loginSteps')) { + setSteps(loginSteps); + setStepName('loginSteps'); + setStepsEnabled(true); + } else if (router.pathname === '/' && !isCompleted('landingSteps')) { + setSteps(landingSteps); + setStepName('landingSteps'); + setStepsEnabled(true); + } else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) { + setTimeout(() => { + setSteps(appSteps); + setStepName('appSteps'); + setStepsEnabled(true); + }, 1000); + } else if ( + router.pathname === '/users/users-list' && + !isCompleted('usersSteps') + ) { + setTimeout(() => { + setSteps(usersSteps); + setStepName('usersSteps'); + setStepsEnabled(true); + }, 1000); + } else if ( + router.pathname === '/roles/roles-list' && + !isCompleted('rolesSteps') + ) { + setTimeout(() => { + setSteps(rolesSteps); + setStepName('rolesSteps'); + setStepsEnabled(true); + }, 1000); + } else { + setSteps([]); + setStepsEnabled(false); + } + }, [router.pathname]); + + const handleExit = () => { + setStepsEnabled(false); + }; + + return ( + + {getLayout( + <> + + + + + + + + + + + + + + + + + + + + + + + + + , + )} + + ); +} + +export default MyApp; diff --git a/frontend/src/pages/administrators/[administratorsId].tsx b/frontend/src/pages/administrators/[administratorsId].tsx new file mode 100644 index 0000000..9fcf6bd --- /dev/null +++ b/frontend/src/pages/administrators/[administratorsId].tsx @@ -0,0 +1,141 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/administrators/administratorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditAdministrators = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + role: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { administrators } = useAppSelector((state) => state.administrators); + + const { administratorsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: administratorsId })); + }, [administratorsId]); + + useEffect(() => { + if (typeof administrators === 'object') { + setInitialValues(administrators); + } + }, [administrators]); + + useEffect(() => { + if (typeof administrators === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = administrators[el]), + ); + + setInitialValues(newInitialVal); + } + }, [administrators]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: administratorsId, data })); + await router.push('/administrators/administrators-list'); + }; + + return ( + <> + + {getPageTitle('Edit administrators')} + + + + {''} + + + handleSubmit(values)} + > +

    + + + + + + + + + + + + + + router.push('/administrators/administrators-list') + } + /> + + + + + + + ); +}; + +EditAdministrators.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditAdministrators; diff --git a/frontend/src/pages/administrators/administrators-edit.tsx b/frontend/src/pages/administrators/administrators-edit.tsx new file mode 100644 index 0000000..eb848de --- /dev/null +++ b/frontend/src/pages/administrators/administrators-edit.tsx @@ -0,0 +1,139 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/administrators/administratorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditAdministratorsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + role: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { administrators } = useAppSelector((state) => state.administrators); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof administrators === 'object') { + setInitialValues(administrators); + } + }, [administrators]); + + useEffect(() => { + if (typeof administrators === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = administrators[el]), + ); + setInitialValues(newInitialVal); + } + }, [administrators]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/administrators/administrators-list'); + }; + + return ( + <> + + {getPageTitle('Edit administrators')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + router.push('/administrators/administrators-list') + } + /> + + +
    +
    +
    + + ); +}; + +EditAdministratorsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditAdministratorsPage; diff --git a/frontend/src/pages/administrators/administrators-list.tsx b/frontend/src/pages/administrators/administrators-list.tsx new file mode 100644 index 0000000..26fb553 --- /dev/null +++ b/frontend/src/pages/administrators/administrators-list.tsx @@ -0,0 +1,175 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableAdministrators from '../../components/Administrators/TableAdministrators'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/administrators/administratorsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const AdministratorsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Role', title: 'role' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_ADMINISTRATORS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getAdministratorsCSV = async () => { + const response = await axios({ + url: '/administrators?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'administratorsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Administrators')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + + Switch to Table + +
    +
    + + + + +
    + + + + + ); +}; + +AdministratorsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AdministratorsTablesPage; diff --git a/frontend/src/pages/administrators/administrators-new.tsx b/frontend/src/pages/administrators/administrators-new.tsx new file mode 100644 index 0000000..f8a89e8 --- /dev/null +++ b/frontend/src/pages/administrators/administrators-new.tsx @@ -0,0 +1,112 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/administrators/administratorsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + user: '', + + role: '', +}; + +const AdministratorsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/administrators/administrators-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + router.push('/administrators/administrators-list') + } + /> + + +
    +
    +
    + + ); +}; + +AdministratorsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AdministratorsNew; diff --git a/frontend/src/pages/administrators/administrators-table.tsx b/frontend/src/pages/administrators/administrators-table.tsx new file mode 100644 index 0000000..fd9c529 --- /dev/null +++ b/frontend/src/pages/administrators/administrators-table.tsx @@ -0,0 +1,172 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableAdministrators from '../../components/Administrators/TableAdministrators'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/administrators/administratorsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const AdministratorsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Role', title: 'role' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_ADMINISTRATORS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getAdministratorsCSV = async () => { + const response = await axios({ + url: '/administrators?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'administratorsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Administrators')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +AdministratorsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AdministratorsTablesPage; diff --git a/frontend/src/pages/administrators/administrators-view.tsx b/frontend/src/pages/administrators/administrators-view.tsx new file mode 100644 index 0000000..fa8b7a8 --- /dev/null +++ b/frontend/src/pages/administrators/administrators-view.tsx @@ -0,0 +1,89 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/administrators/administratorsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const AdministratorsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { administrators } = useAppSelector((state) => state.administrators); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View administrators')} + + + + + + +
    +

    User

    + +

    {administrators?.user?.firstName ?? 'No data'}

    +
    + +
    +

    Role

    +

    {administrators?.role}

    +
    + + + + router.push('/administrators/administrators-list')} + /> +
    +
    + + ); +}; + +AdministratorsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AdministratorsView; diff --git a/frontend/src/pages/api/hello.js b/frontend/src/pages/api/hello.js new file mode 100644 index 0000000..1c39e1f --- /dev/null +++ b/frontend/src/pages/api/hello.js @@ -0,0 +1,5 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction + +export default function helloAPI(req, res) { + res.status(200).json({ name: 'John Doe' }); +} diff --git a/frontend/src/pages/clients/[clientsId].tsx b/frontend/src/pages/clients/[clientsId].tsx new file mode 100644 index 0000000..4c2a4b2 --- /dev/null +++ b/frontend/src/pages/clients/[clientsId].tsx @@ -0,0 +1,150 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditClients = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + mood: '', + + sessions: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { clients } = useAppSelector((state) => state.clients); + + const { clientsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: clientsId })); + }, [clientsId]); + + useEffect(() => { + if (typeof clients === 'object') { + setInitialValues(clients); + } + }, [clients]); + + useEffect(() => { + if (typeof clients === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = clients[el])); + + setInitialValues(newInitialVal); + } + }, [clients]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: clientsId, data })); + await router.push('/clients/clients-list'); + }; + + return ( + <> + + {getPageTitle('Edit clients')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClients.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClients; diff --git a/frontend/src/pages/clients/clients-edit.tsx b/frontend/src/pages/clients/clients-edit.tsx new file mode 100644 index 0000000..775208e --- /dev/null +++ b/frontend/src/pages/clients/clients-edit.tsx @@ -0,0 +1,148 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditClientsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + mood: '', + + sessions: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { clients } = useAppSelector((state) => state.clients); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof clients === 'object') { + setInitialValues(clients); + } + }, [clients]); + + useEffect(() => { + if (typeof clients === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = clients[el])); + setInitialValues(newInitialVal); + } + }, [clients]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/clients/clients-list'); + }; + + return ( + <> + + {getPageTitle('Edit clients')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClientsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClientsPage; diff --git a/frontend/src/pages/clients/clients-list.tsx b/frontend/src/pages/clients/clients-list.tsx new file mode 100644 index 0000000..414bd47 --- /dev/null +++ b/frontend/src/pages/clients/clients-list.tsx @@ -0,0 +1,172 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableClients from '../../components/Clients/TableClients'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/clients/clientsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClientsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Mood', title: 'mood' }, + + { label: 'User', title: 'user' }, + + { label: 'Sessions', title: 'sessions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLIENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClientsCSV = async () => { + const response = await axios({ + url: '/clients?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'clientsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Clients')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +ClientsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsTablesPage; diff --git a/frontend/src/pages/clients/clients-new.tsx b/frontend/src/pages/clients/clients-new.tsx new file mode 100644 index 0000000..5ccb065 --- /dev/null +++ b/frontend/src/pages/clients/clients-new.tsx @@ -0,0 +1,122 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/clients/clientsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + user: '', + + mood: '', + + sessions: [], +}; + +const ClientsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/clients/clients-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +ClientsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsNew; diff --git a/frontend/src/pages/clients/clients-table.tsx b/frontend/src/pages/clients/clients-table.tsx new file mode 100644 index 0000000..764ae27 --- /dev/null +++ b/frontend/src/pages/clients/clients-table.tsx @@ -0,0 +1,171 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableClients from '../../components/Clients/TableClients'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/clients/clientsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClientsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Mood', title: 'mood' }, + + { label: 'User', title: 'user' }, + + { label: 'Sessions', title: 'sessions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLIENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClientsCSV = async () => { + const response = await axios({ + url: '/clients?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'clientsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Clients')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +ClientsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsTablesPage; diff --git a/frontend/src/pages/clients/clients-view.tsx b/frontend/src/pages/clients/clients-view.tsx new file mode 100644 index 0000000..bc7f8ab --- /dev/null +++ b/frontend/src/pages/clients/clients-view.tsx @@ -0,0 +1,179 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/clients/clientsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const ClientsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { clients } = useAppSelector((state) => state.clients); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View clients')} + + + + + + +
    +

    User

    + +

    {clients?.user?.firstName ?? 'No data'}

    +
    + +
    +

    Mood

    +

    {clients?.mood}

    +
    + + <> +

    Sessions

    + +
    + + + + + + + + + + {clients.sessions && + Array.isArray(clients.sessions) && + clients.sessions.map((item: any) => ( + + router.push( + `/sessions/sessions-view/?id=${item.id}`, + ) + } + > + + + + + ))} + +
    StartTimeEndTime
    + {dataFormatter.dateTimeFormatter(item.start_time)} + + {dataFormatter.dateTimeFormatter(item.end_time)} +
    +
    + {!clients?.sessions?.length && ( +
    No data
    + )} +
    + + + <> +

    Sessions Client

    + +
    + + + + + + + + + + {clients.sessions_client && + Array.isArray(clients.sessions_client) && + clients.sessions_client.map((item: any) => ( + + router.push( + `/sessions/sessions-view/?id=${item.id}`, + ) + } + > + + + + + ))} + +
    StartTimeEndTime
    + {dataFormatter.dateTimeFormatter(item.start_time)} + + {dataFormatter.dateTimeFormatter(item.end_time)} +
    +
    + {!clients?.sessions_client?.length && ( +
    No data
    + )} +
    + + + + + router.push('/clients/clients-list')} + /> +
    +
    + + ); +}; + +ClientsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsView; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx new file mode 100644 index 0000000..649955c --- /dev/null +++ b/frontend/src/pages/dashboard.tsx @@ -0,0 +1,472 @@ +import * as icon from '@mdi/js'; +import Head from 'next/head'; +import React from 'react'; +import axios from 'axios'; +import type { ReactElement } from 'react'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import BaseIcon from '../components/BaseIcon'; +import { getPageTitle } from '../config'; +import Link from 'next/link'; + +import { hasPermission } from '../helpers/userPermissions'; +import { fetchWidgets } from '../stores/roles/rolesSlice'; +import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; +import { SmartWidget } from '../components/SmartWidget/SmartWidget'; + +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +const Dashboard = () => { + const dispatch = useAppDispatch(); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + + const [users, setUsers] = React.useState('Loading...'); + const [administrators, setAdministrators] = React.useState('Loading...'); + const [clients, setClients] = React.useState('Loading...'); + const [resources, setResources] = React.useState('Loading...'); + const [sessions, setSessions] = React.useState('Loading...'); + const [supervisors, setSupervisors] = React.useState('Loading...'); + const [therapists, setTherapists] = React.useState('Loading...'); + const [roles, setRoles] = React.useState('Loading...'); + const [permissions, setPermissions] = React.useState('Loading...'); + + const [widgetsRole, setWidgetsRole] = React.useState({ + role: { value: '', label: '' }, + }); + const { currentUser } = useAppSelector((state) => state.auth); + const { isFetchingQuery } = useAppSelector((state) => state.openAi); + + const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + + async function loadData() { + const entities = [ + 'users', + 'administrators', + 'clients', + 'resources', + 'sessions', + 'supervisors', + 'therapists', + 'roles', + 'permissions', + ]; + const fns = [ + setUsers, + setAdministrators, + setClients, + setResources, + setSessions, + setSupervisors, + setTherapists, + setRoles, + setPermissions, + ]; + + const requests = entities.map((entity, index) => { + if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { + return axios.get(`/${entity.toLowerCase()}/count`); + } else { + fns[index](null); + return Promise.resolve({ data: { count: null } }); + } + }); + + Promise.allSettled(requests).then((results) => { + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + fns[i](result.value.data.count); + } else { + fns[i](result.reason.message); + } + }); + }); + } + + async function getWidgets(roleId) { + await dispatch(fetchWidgets(roleId)); + } + React.useEffect(() => { + if (!currentUser) return; + loadData().then(); + setWidgetsRole({ + role: { + value: currentUser?.app_role?.id, + label: currentUser?.app_role?.name, + }, + }); + }, [currentUser]); + + React.useEffect(() => { + if (!currentUser || !widgetsRole?.role?.value) return; + getWidgets(widgetsRole?.role?.value || '').then(); + }, [widgetsRole?.role?.value]); + + return ( + <> + + {getPageTitle('Dashboard')} + + + + {''} + + + {hasPermission(currentUser, 'CREATE_ROLES') && ( + + )} + {!!rolesWidgets.length && + hasPermission(currentUser, 'CREATE_ROLES') && ( +

    + {`${widgetsRole?.role?.label || 'Users'}'s widgets`} +

    + )} + +
    + {(isFetchingQuery || loading) && ( +
    + {' '} + Loading widgets... +
    + )} + + {rolesWidgets && + rolesWidgets.map((widget) => ( + + ))} +
    + + {!!rolesWidgets.length && ( +
    + )} + +
    + {hasPermission(currentUser, 'READ_USERS') && ( + +
    +
    +
    +
    + Users +
    +
    + {users} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_ADMINISTRATORS') && ( + +
    +
    +
    +
    + Administrators +
    +
    + {administrators} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_CLIENTS') && ( + +
    +
    +
    +
    + Clients +
    +
    + {clients} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_RESOURCES') && ( + +
    +
    +
    +
    + Resources +
    +
    + {resources} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_SESSIONS') && ( + +
    +
    +
    +
    + Sessions +
    +
    + {sessions} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_SUPERVISORS') && ( + +
    +
    +
    +
    + Supervisors +
    +
    + {supervisors} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_THERAPISTS') && ( + +
    +
    +
    +
    + Therapists +
    +
    + {therapists} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_ROLES') && ( + +
    +
    +
    +
    + Roles +
    +
    + {roles} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_PERMISSIONS') && ( + +
    +
    +
    +
    + Permissions +
    +
    + {permissions} +
    +
    +
    + +
    +
    +
    + + )} +
    +
    + + ); +}; + +Dashboard.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Dashboard; diff --git a/frontend/src/pages/error.tsx b/frontend/src/pages/error.tsx new file mode 100644 index 0000000..0e80c27 --- /dev/null +++ b/frontend/src/pages/error.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +export default function Error() { + return ( + <> + + {getPageTitle('Error')} + + + + } + > +
    +

    Unhandled exception

    + +

    An Error Occurred

    +
    +
    +
    + + ); +} + +Error.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/forgot.tsx b/frontend/src/pages/forgot.tsx new file mode 100644 index 0000000..071239b --- /dev/null +++ b/frontend/src/pages/forgot.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; +import axios from 'axios'; + +export default function Forgot() { + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const notify = (type, msg) => toast(msg, { type }); + + const handleSubmit = async (value) => { + setLoading(true); + try { + const { data: response } = await axios.post( + '/auth/send-password-reset-email', + value, + ); + setLoading(false); + notify('success', 'Please check your email for verification link'); + setTimeout(async () => { + await router.push('/login'); + }, 3000); + } catch (error) { + setLoading(false); + console.log('error: ', error); + notify('error', 'Something was wrong. Try again'); + } + }; + + return ( + <> + + {getPageTitle('Login')} + + + + + handleSubmit(values)} + > +
    + + + + + + + + + + + +
    +
    +
    + + + ); +} + +Forgot.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/forms.tsx b/frontend/src/pages/forms.tsx new file mode 100644 index 0000000..45de29b --- /dev/null +++ b/frontend/src/pages/forms.tsx @@ -0,0 +1,162 @@ +import { + mdiAccount, + mdiBallotOutline, + mdiGithub, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import Head from 'next/head'; +import { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import CardBox from '../components/CardBox'; +import FormCheckRadio from '../components/FormCheckRadio'; +import FormCheckRadioGroup from '../components/FormCheckRadioGroup'; +import FormField from '../components/FormField'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitle from '../components/SectionTitle'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; + +const FormsPage = () => { + return ( + <> + + {getPageTitle('Forms')} + + + + + {''} + + + + alert(JSON.stringify(values, null, 2))} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + Custom elements + + + + null} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + ); +}; + +FormsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default FormsPage; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx new file mode 100644 index 0000000..e6356b0 --- /dev/null +++ b/frontend/src/pages/index.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../stores/hooks'; +import LayoutGuest from '../layouts/Guest'; +import WebSiteHeader from '../components/WebPageComponents/Header'; +import WebSiteFooter from '../components/WebPageComponents/Footer'; +import { + HeroDesigns, + FeaturesDesigns, + TestimonialsDesigns, + AboutUsDesigns, + ContactFormDesigns, +} from '../components/WebPageComponents/designs'; + +import HeroSection from '../components/WebPageComponents/HeroComponent'; + +import FeaturesSection from '../components/WebPageComponents/FeaturesComponent'; + +import TestimonialsSection from '../components/WebPageComponents/TestimonialsComponent'; + +import AboutUsSection from '../components/WebPageComponents/AboutUsComponent'; + +import ContactFormSection from '../components/WebPageComponents/ContactFormComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + const features_points = [ + { + name: 'Mood Tracking', + description: + 'Monitor your emotional state daily. Gain insights into patterns and triggers to better understand your mental health journey.', + icon: 'mdiEmoticonHappyOutline', + }, + { + name: 'Guided Meditations', + description: + 'Access a library of calming meditations. Enhance your mindfulness and relaxation with expert-guided sessions.', + icon: 'mdiMeditation', + }, + { + name: 'AI Therapy Chat', + description: + 'Engage in supportive conversations with our AI. Receive instant feedback and guidance tailored to your needs.', + icon: 'mdiRobot', + }, + { + name: 'Community Support', + description: + 'Join a community of like-minded individuals. Share experiences and find encouragement in a safe space.', + icon: 'mdiAccountGroup', + }, + { + name: 'Data Analytics', + description: + 'Visualize your progress with detailed analytics. Make informed decisions about your mental health journey.', + icon: 'mdiChartLine', + }, + { + name: 'Therapist Profiles', + description: + 'Connect with qualified therapists. View profiles and choose the right professional for your needs.', + icon: 'mdiAccountBadgeOutline', + }, + ]; + + const testimonials = [ + { + text: '${projectName} has transformed my mental health journey. The mood tracking feature is a game-changer!', + company: 'Mindful Innovations', + user_name: 'Alex Johnson, Wellness Coordinator', + }, + { + text: 'I love the guided meditations. They help me relax and focus. Highly recommend ${projectName}!', + company: 'ZenTech Solutions', + user_name: 'Emily Carter, Meditation Specialist', + }, + { + text: "The AI therapy chat is like having a therapist in my pocket. It's supportive and insightful.", + company: 'Digital Health Corp', + user_name: 'Michael Lee, Product Manager', + }, + { + text: "Community support on ${projectName} is incredible. I've found a safe space to share and grow.", + company: 'Wellness Connect', + user_name: 'Sarah Thompson, Community Manager', + }, + { + text: "Data analytics on ${projectName} provide valuable insights into my mental health patterns. It's empowering.", + company: 'Insightful Minds', + user_name: 'David Brown, Data Analyst', + }, + { + text: 'Connecting with therapists through ${projectName} was seamless. I found the perfect match for my needs.', + company: 'Therapy Network', + user_name: 'Jessica White, Client Relations', + }, + ]; + + return ( +
    + + {`Mental Health Support Platform - Home`} + + + +
    + + + + + + + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx new file mode 100644 index 0000000..48f82b7 --- /dev/null +++ b/frontend/src/pages/login.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import BaseIcon from '../components/BaseIcon'; +import { mdiInformation } from '@mdi/js'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import FormCheckRadio from '../components/FormCheckRadio'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; +import { findMe, loginUser, resetAction } from '../stores/authSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import Link from 'next/link'; +import { toast, ToastContainer } from 'react-toastify'; +import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; + +export default function Login() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const textColor = useAppSelector((state) => state.style.linkColor); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const notify = (type, msg) => toast(msg, { type }); + const [illustrationImage, setIllustrationImage] = useState({ + src: undefined, + photographer: undefined, + photographer_url: undefined, + }); + const [illustrationVideo, setIllustrationVideo] = useState({ + video_files: [], + }); + const [contentType, setContentType] = useState('image'); + const [contentPosition, setContentPosition] = useState('right'); + + const { + currentUser, + isFetching, + errorMessage, + token, + notify: notifyState, + } = useAppSelector((state) => state.auth); + const [initialValues, setInitialValues] = React.useState({ + email: 'admin@flatlogic.com', + password: 'password', + remember: true, + }); + + const title = 'mental health wellbing app'; + + // Fetch Pexels image/video + useEffect(() => { + async function fetchData() { + const image = await getPexelsImage(); + const video = await getPexelsVideo(); + setIllustrationImage(image); + setIllustrationVideo(video); + } + fetchData(); + }, []); + // Fetch user data + useEffect(() => { + if (token) { + dispatch(findMe()); + } + }, [token, dispatch]); + // Redirect to dashboard if user is logged in + useEffect(() => { + if (currentUser?.id) { + router.push('/dashboard'); + } + }, [currentUser?.id, router]); + // Show error message if there is one + useEffect(() => { + if (errorMessage) { + notify('error', errorMessage); + } + }, [errorMessage]); + // Show notification if there is one + useEffect(() => { + if (notifyState?.showNotification) { + notify('success', notifyState?.textNotification); + dispatch(resetAction()); + } + }, [notifyState?.showNotification]); + + const handleSubmit = async (value) => { + const { remember, ...rest } = value; + await dispatch(loginUser(rest)); + }; + + const setLogin = (target) => { + const email = target?.innerText; + setInitialValues((prev) => { + return { ...prev, email, password: 'password' }; + }); + }; + + const imageBlock = (image) => ( + + ); + + const videoBlock = (video) => { + if (video?.video_files?.length > 0) { + return ( +
    + + +
    + ); + } + }; + + return ( +
    + + {getPageTitle('Login')} + + + +
    + {contentType === 'image' && contentPosition !== 'background' + ? imageBlock(illustrationImage) + : null} + {contentType === 'video' && contentPosition !== 'background' + ? videoBlock(illustrationVideo) + : null} +
    + + +

    + {' '} + mental health wellbing app +

    + + +
    +
    +

    + Use{' '} + setLogin(e.target)} + > + admin@flatlogic.com + {' '} + to login as Admin +

    +

    + Use{' '} + setLogin(e.target)} + > + client@hello.com + {' '} + to login as User +

    +
    +
    + +
    +
    +
    + + + handleSubmit(values)} + > +
    + + + + + + + + +
    + + + + + + Forgot password? + +
    + + + + + + +
    +

    + Don’t have account yet?{' '} + + New Account + +

    + +
    +
    +
    +
    +
    +
    +

    + © 2024 {title}. All rights reserved +

    + + Privacy Policy + +
    + +
    + ); +} + +Login.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/password-reset.tsx b/frontend/src/pages/password-reset.tsx new file mode 100644 index 0000000..e75c2f4 --- /dev/null +++ b/frontend/src/pages/password-reset.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import LayoutGuest from '../layouts/Guest'; +import PasswordSetOrReset from '../components/PasswordSetOrReset'; + +export default function Reset() { + return ; +} + +Reset.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/permissions/[permissionsId].tsx b/frontend/src/pages/permissions/[permissionsId].tsx new file mode 100644 index 0000000..c3e482c --- /dev/null +++ b/frontend/src/pages/permissions/[permissionsId].tsx @@ -0,0 +1,126 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditPermissions = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { permissions } = useAppSelector((state) => state.permissions); + + const { permissionsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: permissionsId })); + }, [permissionsId]); + + useEffect(() => { + if (typeof permissions === 'object') { + setInitialValues(permissions); + } + }, [permissions]); + + useEffect(() => { + if (typeof permissions === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = permissions[el]), + ); + + setInitialValues(newInitialVal); + } + }, [permissions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: permissionsId, data })); + await router.push('/permissions/permissions-list'); + }; + + return ( + <> + + {getPageTitle('Edit permissions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPermissions.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPermissions; diff --git a/frontend/src/pages/permissions/permissions-edit.tsx b/frontend/src/pages/permissions/permissions-edit.tsx new file mode 100644 index 0000000..17c0586 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-edit.tsx @@ -0,0 +1,124 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditPermissionsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { permissions } = useAppSelector((state) => state.permissions); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof permissions === 'object') { + setInitialValues(permissions); + } + }, [permissions]); + + useEffect(() => { + if (typeof permissions === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = permissions[el]), + ); + setInitialValues(newInitialVal); + } + }, [permissions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/permissions/permissions-list'); + }; + + return ( + <> + + {getPageTitle('Edit permissions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPermissionsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPermissionsPage; diff --git a/frontend/src/pages/permissions/permissions-list.tsx b/frontend/src/pages/permissions/permissions-list.tsx new file mode 100644 index 0000000..97955e4 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-list.tsx @@ -0,0 +1,165 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TablePermissions from '../../components/Permissions/TablePermissions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/permissions/permissionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PermissionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPermissionsCSV = async () => { + const response = await axios({ + url: '/permissions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'permissionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Permissions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + + +
    + + + + + ); +}; + +PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsTablesPage; diff --git a/frontend/src/pages/permissions/permissions-new.tsx b/frontend/src/pages/permissions/permissions-new.tsx new file mode 100644 index 0000000..e5a9eb0 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-new.tsx @@ -0,0 +1,98 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/permissions/permissionsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', +}; + +const PermissionsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/permissions/permissions-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +PermissionsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsNew; diff --git a/frontend/src/pages/permissions/permissions-table.tsx b/frontend/src/pages/permissions/permissions-table.tsx new file mode 100644 index 0000000..4f54815 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-table.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TablePermissions from '../../components/Permissions/TablePermissions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/permissions/permissionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PermissionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPermissionsCSV = async () => { + const response = await axios({ + url: '/permissions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'permissionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Permissions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + +
    + + + + + ); +}; + +PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsTablesPage; diff --git a/frontend/src/pages/permissions/permissions-view.tsx b/frontend/src/pages/permissions/permissions-view.tsx new file mode 100644 index 0000000..73dde19 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-view.tsx @@ -0,0 +1,83 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/permissions/permissionsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const PermissionsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { permissions } = useAppSelector((state) => state.permissions); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View permissions')} + + + + + + +
    +

    Name

    +

    {permissions?.name}

    +
    + + + + router.push('/permissions/permissions-list')} + /> +
    +
    + + ); +}; + +PermissionsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsView; diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx new file mode 100644 index 0000000..2fca4f8 --- /dev/null +++ b/frontend/src/pages/privacy-policy.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +export default function PrivacyPolicy() { + const title = 'mental health wellbing app'; + const [projectUrl, setProjectUrl] = useState(''); + + useEffect(() => { + setProjectUrl(location.origin); + }, []); + + const Introduction = () => { + return ( + <> +

    1. Introduction

    +

    + {/* eslint-disable-next-line react/no-unescaped-entities */} + We at {title} ("we", "us", "our") are committed to + protecting your privacy. This Privacy Policy explains how we collect, + use, disclose, and safeguard your information when you visit our + website {projectUrl}, use our services, or + interact with us in other ways. By using our services, you agree to + the collection and use of information in accordance with this policy. +

    + + ); + }; + + const Information = () => { + return ( + <> +

    2. Information We Collect

    +
    +

    2.1 Personal Identification Information

    +

    + We collect various types of personal information in connection with + the services we provide, including: +

    +
      +
    • + Contact Information: Name, email address, phone number, mailing + address. +
    • +
    • Account Information: Username, password, profile picture.
    • +
    • Payment Information: Credit card details, billing address.
    • +
    • Demographic Information: Age, gender, interests.
    • +
    +

    2.2 Technical Data

    +

    + We automatically collect certain information when you visit, use, or + navigate our services. This information may include: +

    +
      +
    • + Device Information: IP address, browser type, operating system, + device type. +
    • +
    • + Usage Data: Pages visited, time spent on each page, links clicked, + and other actions taken on our site. +
    • +
    +

    2.3 Cookies and Tracking Technologies

    +

    + We use cookies and similar tracking technologies to track the + activity on our service and hold certain information. You can + instruct your browser to refuse all cookies or to indicate when a + cookie is being sent. +

    +
    + + ); + }; + + const HowToUser = () => { + return ( + <> +

    3. How We Use Your Information

    +

    We use the information we collect in various ways, including to:

    +
      +
    • Provide, operate, and maintain our website and services.
    • +
    • Improve, personalize, and expand our website and services.
    • +
    • Understand and analyze how you use our website and services.
    • +
    • Develop new products, services, features, and functionality.
    • +
    • + Communicate with you, either directly or through one of our + partners, including for customer service, to provide you with + updates and other information relating to the website, and for + marketing and promotional purposes. +
    • +
    • + Process your transactions and send you related information, + including purchase confirmations and invoices. +
    • +
    • Find and prevent fraud.
    • +
    • Comply with legal obligations.
    • +
    + + ); + }; + + const DataProtection = () => { + return ( + <> +

    4. Data Protection and Security

    +

    + We implement a variety of security measures to maintain the safety of + your personal information. These measures include: +

    +
      +
    • + Encryption: We use encryption to protect sensitive information + transmitted online. Access Controls: We restrict access to your + personal data to authorized personnel only. Regular Security Audits: + We conduct regular audits to identify and address potential security + vulnerabilities. +
    • +
    + + ); + }; + + const Sharing = () => { + return ( + <> +

    5. Sharing Your Information

    +

    + We do not sell, trade, or otherwise transfer your Personally + Identifiable Information to outside parties without your consent, + except in the following cases: +

    +
      +
    • + Service Providers: We may share your information with third-party + service providers who perform services on our behalf, such as + payment processing, data analysis, email delivery, hosting services, + customer service, and marketing assistance. +
    • +
    • + Business Transfers: In the event of a merger, acquisition, or sale + of all or a portion of our assets, your information may be + transferred as part of that transaction. +
    • +
    • + Legal Requirements: We may disclose your information if required to + do so by law or in response to valid requests by public authorities + (e.g., a court or a government agency). +
    • +
    + + ); + }; + + const ProtectionRights = () => { + return ( + <> +

    6. Your Data Protection Rights

    +

    + Depending on your location, you may have the following rights + regarding your personal data: +

    +
      +
    • + The Right to Access: You have the right to request copies of your + personal data. +
    • +
    • + The Right to Rectification: You have the right to request that we + correct any information you believe is inaccurate or complete + information you believe is incomplete. +
    • +
    • + The Right to Erasure: You have the right to request that we erase + your personal data, under certain conditions. +
    • +
    • + The Right to Restrict Processing: You have the right to request that + we restrict the processing of your personal data, under certain + conditions. +
    • +
    • + The Right to Object to Processing: You have the right to object to + our processing of your personal data, under certain conditions. +
    • +
    • + The Right to Data Portability: You have the right to request that we + transfer the data that we have collected to another organization, or + directly to you, under certain conditions. +
    • +
    + + ); + }; + + const DataTransfers = () => { + return ( + <> +

    7. International Data Transfers

    +

    + Your information, including personal data, may be transferred to — and + maintained on — computers located outside of your state, province, + country, or other governmental jurisdiction where the data protection + laws may differ from those of your jurisdiction. We will take all + steps reasonably necessary to ensure that your data is treated + securely and in accordance with this Privacy Policy. +

    + + ); + }; + + const RetentionOfData = () => { + return ( + <> +

    8. Retention of Data

    +

    + We will retain your personal data only for as long as is necessary for + the purposes set out in this Privacy Policy. We will retain and use + your personal data to the extent necessary to comply with our legal + obligations, resolve disputes, and enforce our policies. +

    + + ); + }; + + const ChangePrivacy = () => { + return ( + <> +

    9. Changes to This Privacy Policy

    +

    + We may update our Privacy Policy from time to time. We will notify you + of any changes by posting the new Privacy Policy on this page. You are + advised to review this Privacy Policy periodically for any changes. + Changes to this Privacy Policy are effective when they are posted on + this page. +

    + + ); + }; + + const ContactUs = () => { + return ( + <> +

    10. Contact Us

    +

    + If you have any questions about this Privacy Policy, please contact + us: +

    +
    + By email:{' '} + [support@flatlogic.com] +
    +
    + By visiting this page on our website:{' '} + Contact Us +
    + + ); + }; + + return ( +
    + + {getPageTitle('Privacy Policy')} + + +
    +
    +
    +

    Privacy Policy

    + + + + + + + + + + +
    +
    +
    +
    + ); +} + +PrivacyPolicy.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx new file mode 100644 index 0000000..efc9070 --- /dev/null +++ b/frontend/src/pages/profile.tsx @@ -0,0 +1,178 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +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 { 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 FormImagePicker from '../components/FormImagePicker'; +import { SwitchField } from '../components/SwitchField'; +import { SelectField } from '../components/SelectField'; + +import { update, fetch } from '../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; +import { findMe } from '../stores/authSlice'; + +const EditUsers = () => { + const { currentUser, isFetching, token } = useAppSelector( + (state) => state.auth, + ); + const router = useRouter(); + const dispatch = useAppDispatch(); + const notify = (type, msg) => toast(msg, { type }); + const initVals = { + firstName: '', + lastName: '', + phoneNumber: '', + email: '', + app_role: '', + disabled: false, + avatar: [], + password: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + useEffect(() => { + if (currentUser?.id && typeof currentUser === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = currentUser[el]), + ); + + setInitialValues(newInitialVal); + } + }, [currentUser]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: currentUser.id, data })); + await dispatch(findMe()); + await router.push('/users/users-list'); + notify('success', 'Profile was updated!'); + }; + + return ( + <> + + {getPageTitle('Edit profile')} + + + + {''} + + + {currentUser?.avatar[0]?.publicUrl && ( +
    +
    + Avatar +
    +
    + )} + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/users/users-list')} + /> + + +
    +
    +
    + + ); +}; + +EditUsers.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default EditUsers; diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx new file mode 100644 index 0000000..40ab874 --- /dev/null +++ b/frontend/src/pages/register.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; + +import axios from 'axios'; + +export default function Register() { + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const handleSubmit = async (value) => { + setLoading(true); + try { + const { data: response } = await axios.post('/auth/signup', value); + await router.push('/login'); + setLoading(false); + notify('success', 'Please check your email for verification link'); + } catch (error) { + setLoading(false); + console.log('error: ', error); + notify('error', 'Something was wrong. Try again'); + } + }; + + return ( + <> + + {getPageTitle('Login')} + + + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + +
    +
    +
    + + + ); +} + +Register.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/resources/[resourcesId].tsx b/frontend/src/pages/resources/[resourcesId].tsx new file mode 100644 index 0000000..966af7b --- /dev/null +++ b/frontend/src/pages/resources/[resourcesId].tsx @@ -0,0 +1,148 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/resources/resourcesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditResources = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + title: '', + + content: '', + + type: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { resources } = useAppSelector((state) => state.resources); + + const { resourcesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: resourcesId })); + }, [resourcesId]); + + useEffect(() => { + if (typeof resources === 'object') { + setInitialValues(resources); + } + }, [resources]); + + useEffect(() => { + if (typeof resources === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = resources[el]), + ); + + setInitialValues(newInitialVal); + } + }, [resources]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: resourcesId, data })); + await router.push('/resources/resources-list'); + }; + + return ( + <> + + {getPageTitle('Edit resources')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + router.push('/resources/resources-list')} + /> + + +
    +
    +
    + + ); +}; + +EditResources.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditResources; diff --git a/frontend/src/pages/resources/resources-edit.tsx b/frontend/src/pages/resources/resources-edit.tsx new file mode 100644 index 0000000..d005305 --- /dev/null +++ b/frontend/src/pages/resources/resources-edit.tsx @@ -0,0 +1,146 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/resources/resourcesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditResourcesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + title: '', + + content: '', + + type: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { resources } = useAppSelector((state) => state.resources); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof resources === 'object') { + setInitialValues(resources); + } + }, [resources]); + + useEffect(() => { + if (typeof resources === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = resources[el]), + ); + setInitialValues(newInitialVal); + } + }, [resources]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/resources/resources-list'); + }; + + return ( + <> + + {getPageTitle('Edit resources')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + router.push('/resources/resources-list')} + /> + + +
    +
    +
    + + ); +}; + +EditResourcesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditResourcesPage; diff --git a/frontend/src/pages/resources/resources-list.tsx b/frontend/src/pages/resources/resources-list.tsx new file mode 100644 index 0000000..463531f --- /dev/null +++ b/frontend/src/pages/resources/resources-list.tsx @@ -0,0 +1,174 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableResources from '../../components/Resources/TableResources'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/resources/resourcesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ResourcesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Title', title: 'title' }, + { label: 'Content', title: 'content' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['meditation', 'article', 'video'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_RESOURCES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getResourcesCSV = async () => { + const response = await axios({ + url: '/resources?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'resourcesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Resources')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + +
    + + + + + ); +}; + +ResourcesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ResourcesTablesPage; diff --git a/frontend/src/pages/resources/resources-new.tsx b/frontend/src/pages/resources/resources-new.tsx new file mode 100644 index 0000000..44326b2 --- /dev/null +++ b/frontend/src/pages/resources/resources-new.tsx @@ -0,0 +1,120 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/resources/resourcesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + title: '', + + content: '', + + type: 'meditation', +}; + +const ResourcesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/resources/resources-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + router.push('/resources/resources-list')} + /> + + +
    +
    +
    + + ); +}; + +ResourcesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ResourcesNew; diff --git a/frontend/src/pages/resources/resources-table.tsx b/frontend/src/pages/resources/resources-table.tsx new file mode 100644 index 0000000..37c7267 --- /dev/null +++ b/frontend/src/pages/resources/resources-table.tsx @@ -0,0 +1,175 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableResources from '../../components/Resources/TableResources'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/resources/resourcesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ResourcesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Title', title: 'title' }, + { label: 'Content', title: 'content' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['meditation', 'article', 'video'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_RESOURCES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getResourcesCSV = async () => { + const response = await axios({ + url: '/resources?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'resourcesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Resources')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to kanban + +
    +
    + + + +
    + + + + + ); +}; + +ResourcesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ResourcesTablesPage; diff --git a/frontend/src/pages/resources/resources-view.tsx b/frontend/src/pages/resources/resources-view.tsx new file mode 100644 index 0000000..b5a4d62 --- /dev/null +++ b/frontend/src/pages/resources/resources-view.tsx @@ -0,0 +1,97 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/resources/resourcesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const ResourcesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { resources } = useAppSelector((state) => state.resources); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View resources')} + + + + + + +
    +

    Title

    +

    {resources?.title}

    +
    + +
    +

    Content

    + {resources.content ? ( +

    + ) : ( +

    No data

    + )} +
    + +
    +

    Type

    +

    {resources?.type ?? 'No data'}

    +
    + + + + router.push('/resources/resources-list')} + /> +
    +
    + + ); +}; + +ResourcesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ResourcesView; diff --git a/frontend/src/pages/roles/[rolesId].tsx b/frontend/src/pages/roles/[rolesId].tsx new file mode 100644 index 0000000..3918191 --- /dev/null +++ b/frontend/src/pages/roles/[rolesId].tsx @@ -0,0 +1,137 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/roles/rolesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditRoles = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + + permissions: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { roles } = useAppSelector((state) => state.roles); + + const { rolesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: rolesId })); + }, [rolesId]); + + useEffect(() => { + if (typeof roles === 'object') { + setInitialValues(roles); + } + }, [roles]); + + useEffect(() => { + if (typeof roles === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = roles[el])); + + setInitialValues(newInitialVal); + } + }, [roles]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: rolesId, data })); + await router.push('/roles/roles-list'); + }; + + return ( + <> + + {getPageTitle('Edit roles')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/roles/roles-list')} + /> + + +
    +
    +
    + + ); +}; + +EditRoles.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditRoles; diff --git a/frontend/src/pages/roles/roles-edit.tsx b/frontend/src/pages/roles/roles-edit.tsx new file mode 100644 index 0000000..88500cd --- /dev/null +++ b/frontend/src/pages/roles/roles-edit.tsx @@ -0,0 +1,135 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/roles/rolesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditRolesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + + permissions: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { roles } = useAppSelector((state) => state.roles); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof roles === 'object') { + setInitialValues(roles); + } + }, [roles]); + + useEffect(() => { + if (typeof roles === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = roles[el])); + setInitialValues(newInitialVal); + } + }, [roles]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/roles/roles-list'); + }; + + return ( + <> + + {getPageTitle('Edit roles')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/roles/roles-list')} + /> + + +
    +
    +
    + + ); +}; + +EditRolesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditRolesPage; diff --git a/frontend/src/pages/roles/roles-list.tsx b/frontend/src/pages/roles/roles-list.tsx new file mode 100644 index 0000000..5c07566 --- /dev/null +++ b/frontend/src/pages/roles/roles-list.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableRoles from '../../components/Roles/TableRoles'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/roles/rolesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const RolesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Name', title: 'name' }, + + { label: 'Permissions', title: 'permissions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_ROLES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getRolesCSV = async () => { + const response = await axios({ + url: '/roles?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'rolesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Roles')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + + +
    + + + + + ); +}; + +RolesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default RolesTablesPage; diff --git a/frontend/src/pages/roles/roles-new.tsx b/frontend/src/pages/roles/roles-new.tsx new file mode 100644 index 0000000..1c068c1 --- /dev/null +++ b/frontend/src/pages/roles/roles-new.tsx @@ -0,0 +1,110 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/roles/rolesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', + + permissions: [], +}; + +const RolesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/roles/roles-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/roles/roles-list')} + /> + + +
    +
    +
    + + ); +}; + +RolesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default RolesNew; diff --git a/frontend/src/pages/roles/roles-table.tsx b/frontend/src/pages/roles/roles-table.tsx new file mode 100644 index 0000000..380cca9 --- /dev/null +++ b/frontend/src/pages/roles/roles-table.tsx @@ -0,0 +1,163 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableRoles from '../../components/Roles/TableRoles'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/roles/rolesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const RolesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Name', title: 'name' }, + + { label: 'Permissions', title: 'permissions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_ROLES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getRolesCSV = async () => { + const response = await axios({ + url: '/roles?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'rolesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Roles')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + +
    + + + + + ); +}; + +RolesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default RolesTablesPage; diff --git a/frontend/src/pages/roles/roles-view.tsx b/frontend/src/pages/roles/roles-view.tsx new file mode 100644 index 0000000..b7368ff --- /dev/null +++ b/frontend/src/pages/roles/roles-view.tsx @@ -0,0 +1,171 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/roles/rolesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const RolesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { roles } = useAppSelector((state) => state.roles); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View roles')} + + + + + + +
    +

    Name

    +

    {roles?.name}

    +
    + + <> +

    Permissions

    + +
    + + + + + + + + {roles.permissions && + Array.isArray(roles.permissions) && + roles.permissions.map((item: any) => ( + + router.push( + `/permissions/permissions-view/?id=${item.id}`, + ) + } + > + + + ))} + +
    Name
    {item.name}
    +
    + {!roles?.permissions?.length && ( +
    No data
    + )} +
    + + + <> +

    Users App Role

    + +
    + + + + + + + + + + + + + + + + {roles.users_app_role && + Array.isArray(roles.users_app_role) && + roles.users_app_role.map((item: any) => ( + + router.push(`/users/users-view/?id=${item.id}`) + } + > + + + + + + + + + + + ))} + +
    First NameLast NamePhone NumberE-MailDisabled
    {item.firstName}{item.lastName}{item.phoneNumber}{item.email} + {dataFormatter.booleanFormatter(item.disabled)} +
    +
    + {!roles?.users_app_role?.length && ( +
    No data
    + )} +
    + + + + + router.push('/roles/roles-list')} + /> +
    +
    + + ); +}; + +RolesView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default RolesView; diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx new file mode 100644 index 0000000..f64cd5c --- /dev/null +++ b/frontend/src/pages/search.tsx @@ -0,0 +1,93 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import Head from 'next/head'; +import 'react-datepicker/dist/react-datepicker.css'; +import { useAppDispatch } from '../stores/hooks'; + +import { useRouter } from 'next/router'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import SectionMain from '../components/SectionMain'; +import CardBox from '../components/CardBox'; +import SearchResults from '../components/SearchResults'; +import LoadingSpinner from '../components/LoadingSpinner'; +import BaseButton from '../components/BaseButton'; +import BaseDivider from '../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; + +const SearchView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const searchQuery = router.query.query; + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState([]); + + useEffect(() => { + dispatch(fetchData()); + }, [dispatch, searchQuery]); + + const fetchData = createAsyncThunk('/search', async () => { + setLoading(true); + try { + const response = await axios.post('/search', { searchQuery }); + setSearchResults(response.data); + setLoading(false); + return response.data; + } catch (error) { + console.error(error.response); + setLoading(false); + throw error; + } + }); + + const groupedResults = searchResults.reduce((acc, item) => { + const { tableName } = item; + acc[tableName] = acc[tableName] || []; + acc[tableName].push(item); + return acc; + }, {}); + + return ( + <> + + Search Result + + + + {''} + + + {loading ? ( + + ) : ( + + )} + + router.push('/dashboard')} + /> + + + + ); +}; + +SearchView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SearchView; diff --git a/frontend/src/pages/sessions/[sessionsId].tsx b/frontend/src/pages/sessions/[sessionsId].tsx new file mode 100644 index 0000000..c6feb62 --- /dev/null +++ b/frontend/src/pages/sessions/[sessionsId].tsx @@ -0,0 +1,196 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/sessions/sessionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditSessions = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + therapist: null, + + client: null, + + start_time: new Date(), + + end_time: new Date(), + + notes: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { sessions } = useAppSelector((state) => state.sessions); + + const { sessionsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: sessionsId })); + }, [sessionsId]); + + useEffect(() => { + if (typeof sessions === 'object') { + setInitialValues(sessions); + } + }, [sessions]); + + useEffect(() => { + if (typeof sessions === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = sessions[el])); + + setInitialValues(newInitialVal); + } + }, [sessions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: sessionsId, data })); + await router.push('/sessions/sessions-list'); + }; + + return ( + <> + + {getPageTitle('Edit sessions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + setInitialValues({ ...initialValues, start_time: date }) + } + /> + + + + + setInitialValues({ ...initialValues, end_time: date }) + } + /> + + + + + + + + + + + router.push('/sessions/sessions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditSessions.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSessions; diff --git a/frontend/src/pages/sessions/sessions-edit.tsx b/frontend/src/pages/sessions/sessions-edit.tsx new file mode 100644 index 0000000..1201350 --- /dev/null +++ b/frontend/src/pages/sessions/sessions-edit.tsx @@ -0,0 +1,194 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/sessions/sessionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditSessionsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + therapist: null, + + client: null, + + start_time: new Date(), + + end_time: new Date(), + + notes: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { sessions } = useAppSelector((state) => state.sessions); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof sessions === 'object') { + setInitialValues(sessions); + } + }, [sessions]); + + useEffect(() => { + if (typeof sessions === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = sessions[el])); + setInitialValues(newInitialVal); + } + }, [sessions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/sessions/sessions-list'); + }; + + return ( + <> + + {getPageTitle('Edit sessions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + setInitialValues({ ...initialValues, start_time: date }) + } + /> + + + + + setInitialValues({ ...initialValues, end_time: date }) + } + /> + + + + + + + + + + + router.push('/sessions/sessions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditSessionsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSessionsPage; diff --git a/frontend/src/pages/sessions/sessions-list.tsx b/frontend/src/pages/sessions/sessions-list.tsx new file mode 100644 index 0000000..63377d1 --- /dev/null +++ b/frontend/src/pages/sessions/sessions-list.tsx @@ -0,0 +1,175 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableSessions from '../../components/Sessions/TableSessions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/sessions/sessionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SessionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Notes', title: 'notes' }, + + { label: 'StartTime', title: 'start_time', date: 'true' }, + { label: 'EndTime', title: 'end_time', date: 'true' }, + + { label: 'Therapist', title: 'therapist' }, + + { label: 'Client', title: 'client' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SESSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSessionsCSV = async () => { + const response = await axios({ + url: '/sessions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'sessionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Sessions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +SessionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SessionsTablesPage; diff --git a/frontend/src/pages/sessions/sessions-new.tsx b/frontend/src/pages/sessions/sessions-new.tsx new file mode 100644 index 0000000..3da37d2 --- /dev/null +++ b/frontend/src/pages/sessions/sessions-new.tsx @@ -0,0 +1,158 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/sessions/sessionsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + therapist: '', + + client: '', + + start_time: '', + + end_time: '', + + notes: '', +}; + +const SessionsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + // get from url params + const { dateRangeStart, dateRangeEnd } = router.query; + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/sessions/sessions-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/sessions/sessions-list')} + /> + + +
    +
    +
    + + ); +}; + +SessionsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SessionsNew; diff --git a/frontend/src/pages/sessions/sessions-table.tsx b/frontend/src/pages/sessions/sessions-table.tsx new file mode 100644 index 0000000..30d30c0 --- /dev/null +++ b/frontend/src/pages/sessions/sessions-table.tsx @@ -0,0 +1,174 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableSessions from '../../components/Sessions/TableSessions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/sessions/sessionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SessionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Notes', title: 'notes' }, + + { label: 'StartTime', title: 'start_time', date: 'true' }, + { label: 'EndTime', title: 'end_time', date: 'true' }, + + { label: 'Therapist', title: 'therapist' }, + + { label: 'Client', title: 'client' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SESSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSessionsCSV = async () => { + const response = await axios({ + url: '/sessions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'sessionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Sessions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to calendar + +
    +
    + + + +
    + + + + + ); +}; + +SessionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SessionsTablesPage; diff --git a/frontend/src/pages/sessions/sessions-view.tsx b/frontend/src/pages/sessions/sessions-view.tsx new file mode 100644 index 0000000..73b7b25 --- /dev/null +++ b/frontend/src/pages/sessions/sessions-view.tsx @@ -0,0 +1,137 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/sessions/sessionsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const SessionsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { sessions } = useAppSelector((state) => state.sessions); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View sessions')} + + + + + + +
    +

    Therapist

    + +

    {sessions?.therapist?.specialization ?? 'No data'}

    +
    + +
    +

    Client

    + +

    {sessions?.client?.mood ?? 'No data'}

    +
    + + + {sessions.start_time ? ( + + ) : ( +

    No StartTime

    + )} +
    + + + {sessions.end_time ? ( + + ) : ( +

    No EndTime

    + )} +
    + +
    +

    Notes

    + {sessions.notes ? ( +

    + ) : ( +

    No data

    + )} +
    + + + + router.push('/sessions/sessions-list')} + /> +
    +
    + + ); +}; + +SessionsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SessionsView; diff --git a/frontend/src/pages/supervisors/[supervisorsId].tsx b/frontend/src/pages/supervisors/[supervisorsId].tsx new file mode 100644 index 0000000..e1a450e --- /dev/null +++ b/frontend/src/pages/supervisors/[supervisorsId].tsx @@ -0,0 +1,146 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/supervisors/supervisorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditSupervisors = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + therapists: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { supervisors } = useAppSelector((state) => state.supervisors); + + const { supervisorsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: supervisorsId })); + }, [supervisorsId]); + + useEffect(() => { + if (typeof supervisors === 'object') { + setInitialValues(supervisors); + } + }, [supervisors]); + + useEffect(() => { + if (typeof supervisors === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = supervisors[el]), + ); + + setInitialValues(newInitialVal); + } + }, [supervisors]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: supervisorsId, data })); + await router.push('/supervisors/supervisors-list'); + }; + + return ( + <> + + {getPageTitle('Edit supervisors')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/supervisors/supervisors-list')} + /> + + +
    +
    +
    + + ); +}; + +EditSupervisors.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSupervisors; diff --git a/frontend/src/pages/supervisors/supervisors-edit.tsx b/frontend/src/pages/supervisors/supervisors-edit.tsx new file mode 100644 index 0000000..d00fd2b --- /dev/null +++ b/frontend/src/pages/supervisors/supervisors-edit.tsx @@ -0,0 +1,144 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/supervisors/supervisorsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditSupervisorsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + therapists: [], + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { supervisors } = useAppSelector((state) => state.supervisors); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof supervisors === 'object') { + setInitialValues(supervisors); + } + }, [supervisors]); + + useEffect(() => { + if (typeof supervisors === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = supervisors[el]), + ); + setInitialValues(newInitialVal); + } + }, [supervisors]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/supervisors/supervisors-list'); + }; + + return ( + <> + + {getPageTitle('Edit supervisors')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/supervisors/supervisors-list')} + /> + + +
    +
    +
    + + ); +}; + +EditSupervisorsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSupervisorsPage; diff --git a/frontend/src/pages/supervisors/supervisors-list.tsx b/frontend/src/pages/supervisors/supervisors-list.tsx new file mode 100644 index 0000000..03461b7 --- /dev/null +++ b/frontend/src/pages/supervisors/supervisors-list.tsx @@ -0,0 +1,173 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableSupervisors from '../../components/Supervisors/TableSupervisors'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/supervisors/supervisorsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SupervisorsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'User', title: 'user' }, + + { label: 'Therapists', title: 'therapists' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SUPERVISORS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSupervisorsCSV = async () => { + const response = await axios({ + url: '/supervisors?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'supervisorsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Supervisors')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +SupervisorsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SupervisorsTablesPage; diff --git a/frontend/src/pages/supervisors/supervisors-new.tsx b/frontend/src/pages/supervisors/supervisors-new.tsx new file mode 100644 index 0000000..17a629e --- /dev/null +++ b/frontend/src/pages/supervisors/supervisors-new.tsx @@ -0,0 +1,116 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/supervisors/supervisorsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + user: '', + + therapists: [], +}; + +const SupervisorsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/supervisors/supervisors-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/supervisors/supervisors-list')} + /> + + +
    +
    +
    + + ); +}; + +SupervisorsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SupervisorsNew; diff --git a/frontend/src/pages/supervisors/supervisors-table.tsx b/frontend/src/pages/supervisors/supervisors-table.tsx new file mode 100644 index 0000000..6840d4a --- /dev/null +++ b/frontend/src/pages/supervisors/supervisors-table.tsx @@ -0,0 +1,172 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableSupervisors from '../../components/Supervisors/TableSupervisors'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/supervisors/supervisorsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SupervisorsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'User', title: 'user' }, + + { label: 'Therapists', title: 'therapists' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SUPERVISORS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSupervisorsCSV = async () => { + const response = await axios({ + url: '/supervisors?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'supervisorsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Supervisors')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +SupervisorsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SupervisorsTablesPage; diff --git a/frontend/src/pages/supervisors/supervisors-view.tsx b/frontend/src/pages/supervisors/supervisors-view.tsx new file mode 100644 index 0000000..718f41a --- /dev/null +++ b/frontend/src/pages/supervisors/supervisors-view.tsx @@ -0,0 +1,123 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/supervisors/supervisorsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const SupervisorsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { supervisors } = useAppSelector((state) => state.supervisors); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View supervisors')} + + + + + + +
    +

    User

    + +

    {supervisors?.user?.firstName ?? 'No data'}

    +
    + + <> +

    Therapists

    + +
    + + + + + + + + {supervisors.therapists && + Array.isArray(supervisors.therapists) && + supervisors.therapists.map((item: any) => ( + + router.push( + `/therapists/therapists-view/?id=${item.id}`, + ) + } + > + + + ))} + +
    Specialization
    + {item.specialization} +
    +
    + {!supervisors?.therapists?.length && ( +
    No data
    + )} +
    + + + + + router.push('/supervisors/supervisors-list')} + /> +
    +
    + + ); +}; + +SupervisorsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SupervisorsView; diff --git a/frontend/src/pages/tables.tsx b/frontend/src/pages/tables.tsx new file mode 100644 index 0000000..626dc0e --- /dev/null +++ b/frontend/src/pages/tables.tsx @@ -0,0 +1,37 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../components/CardBox'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import TableSampleClients from '../components/TableSampleClients'; +import { getPageTitle } from '../config'; + +const TablesPage = () => { + return ( + <> + + {getPageTitle('Tables')} + + + + {''} + + + + + + + ); +}; + +TablesPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default TablesPage; diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx new file mode 100644 index 0000000..04201fd --- /dev/null +++ b/frontend/src/pages/terms-of-use.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +export default function PrivacyPolicy() { + const title = 'mental health wellbing app'; + const [projectUrl, setProjectUrl] = useState(''); + + useEffect(() => { + setProjectUrl(location.origin); + }, []); + + const Information = () => { + return ( + <> +

    1. Acceptance of Terms

    +
    +

    + By accessing and using our application, you agree to comply with and + be bound by these Terms of Use. If you do not agree to these terms, + please do not use the application. +

    +
    + + ); + }; + + const ChangesTerms = () => { + return ( + <> +

    2. Changes to Terms

    +

    + We reserve the right to modify these Terms of Use at any time. Any + changes will be effective immediately upon posting. Your continued use + of the application after any such changes constitutes your acceptance + of the new terms. +

    + + ); + }; + + const UseApplication = () => { + return ( + <> +

    3. Use of the Application

    +

    + You agree to use the application only for lawful purposes and in a way + that does not infringe the rights of, restrict, or inhibit anyone + else’s use and enjoyment of the application. Prohibited behavior + includes harassing or causing distress or inconvenience to any other + user, transmitting obscene or offensive content, or disrupting the + normal flow of dialogue within the application. +

    + + ); + }; + + const IntellectualProperty = () => { + return ( + <> +

    4. Intellectual Property

    +

    + All content included on the application, such as text, graphics, + logos, images, and software, is the property of {title} or its content + suppliers and protected by international copyright laws. Unauthorized + use of the content may violate copyright, trademark, and other laws. +

    + + ); + }; + + const UserContent = () => { + return ( + <> +

    5. User Content

    +

    + You are responsible for any content you upload, post, or otherwise + make available through the application. You grant {title}a worldwide, + irrevocable, non-exclusive, royalty-free license to use, reproduce, + modify, publish, and distribute such content for any purpose. +

    + + ); + }; + + const Privacy = () => { + return ( + <> +

    6. Privacy

    +

    + Your privacy is important to us. Please review our Privacy Policy to + understand our practices regarding the collection, use, and disclosure + of your personal information. +

    + + ); + }; + + const Liability = () => { + return ( + <> +

    7. Limitation of Liability

    +

    + The application is provided “as is” and “as available” without any + warranties of any kind, either express or implied. {title} + does not warrant that the application will be uninterrupted or + error-free. In no event shall {title} be liable for any damages + arising out of your use of the application. +

    + + ); + }; + + const Indemnification = () => { + return ( + <> +

    8. Indemnification

    +

    + You agree to indemnify, defend, and hold harmless {title}, its + officers, directors, employees, and agents from and against any + claims, liabilities, damages, losses, and expenses, including without + limitation reasonable legal and accounting fees, arising out of or in + any way connected with your access to or use of the application or + your violation of these Terms of Use. +

    + + ); + }; + + const Termination = () => { + return ( + <> +

    9. Termination

    +

    + We reserve the right to terminate or suspend your access to the + application at our sole discretion, without notice and without + liability, for any reason, including if we believe you have violated + these Terms of Use. +

    + + ); + }; + + const GoverningLaw = () => { + return ( + <> +

    10. Governing Law

    +

    + These Terms of Use are governed by and interpreted in accordance with + applicable laws, without regard to any conflict of law principles. You + agree to submit to the exclusive jurisdiction of the courts that have + authority to resolve any dispute arising from the use of the + application. +

    + + ); + }; + + const ContactUs = () => { + return ( + <> +

    11. Contact Information

    +

    + If you have any questions about these Terms of Use, please contact us + at:{' '} + [support@flatlogic.com] +

    + + ); + }; + + return ( +
    + + {getPageTitle('Terms of Use')} + + +
    +
    +
    +

    Terms of Use

    + + + + + + + + + + + + +
    +
    +
    +
    + ); +} + +PrivacyPolicy.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/therapists/[therapistsId].tsx b/frontend/src/pages/therapists/[therapistsId].tsx new file mode 100644 index 0000000..29f0117 --- /dev/null +++ b/frontend/src/pages/therapists/[therapistsId].tsx @@ -0,0 +1,139 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/therapists/therapistsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditTherapists = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + specialization: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { therapists } = useAppSelector((state) => state.therapists); + + const { therapistsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: therapistsId })); + }, [therapistsId]); + + useEffect(() => { + if (typeof therapists === 'object') { + setInitialValues(therapists); + } + }, [therapists]); + + useEffect(() => { + if (typeof therapists === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = therapists[el]), + ); + + setInitialValues(newInitialVal); + } + }, [therapists]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: therapistsId, data })); + await router.push('/therapists/therapists-list'); + }; + + return ( + <> + + {getPageTitle('Edit therapists')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/therapists/therapists-list')} + /> + + +
    +
    +
    + + ); +}; + +EditTherapists.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditTherapists; diff --git a/frontend/src/pages/therapists/therapists-edit.tsx b/frontend/src/pages/therapists/therapists-edit.tsx new file mode 100644 index 0000000..200d37f --- /dev/null +++ b/frontend/src/pages/therapists/therapists-edit.tsx @@ -0,0 +1,137 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/therapists/therapistsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditTherapistsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + user: null, + + specialization: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { therapists } = useAppSelector((state) => state.therapists); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof therapists === 'object') { + setInitialValues(therapists); + } + }, [therapists]); + + useEffect(() => { + if (typeof therapists === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = therapists[el]), + ); + setInitialValues(newInitialVal); + } + }, [therapists]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/therapists/therapists-list'); + }; + + return ( + <> + + {getPageTitle('Edit therapists')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/therapists/therapists-list')} + /> + + +
    +
    +
    + + ); +}; + +EditTherapistsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditTherapistsPage; diff --git a/frontend/src/pages/therapists/therapists-list.tsx b/frontend/src/pages/therapists/therapists-list.tsx new file mode 100644 index 0000000..9b8e302 --- /dev/null +++ b/frontend/src/pages/therapists/therapists-list.tsx @@ -0,0 +1,170 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableTherapists from '../../components/Therapists/TableTherapists'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/therapists/therapistsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TherapistsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Specialization', title: 'specialization' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_THERAPISTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTherapistsCSV = async () => { + const response = await axios({ + url: '/therapists?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'therapistsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Therapists')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +TherapistsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default TherapistsTablesPage; diff --git a/frontend/src/pages/therapists/therapists-new.tsx b/frontend/src/pages/therapists/therapists-new.tsx new file mode 100644 index 0000000..53a64df --- /dev/null +++ b/frontend/src/pages/therapists/therapists-new.tsx @@ -0,0 +1,110 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/therapists/therapistsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + user: '', + + specialization: '', +}; + +const TherapistsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/therapists/therapists-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + router.push('/therapists/therapists-list')} + /> + + +
    +
    +
    + + ); +}; + +TherapistsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default TherapistsNew; diff --git a/frontend/src/pages/therapists/therapists-table.tsx b/frontend/src/pages/therapists/therapists-table.tsx new file mode 100644 index 0000000..d962355 --- /dev/null +++ b/frontend/src/pages/therapists/therapists-table.tsx @@ -0,0 +1,169 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableTherapists from '../../components/Therapists/TableTherapists'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/therapists/therapistsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TherapistsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Specialization', title: 'specialization' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_THERAPISTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTherapistsCSV = async () => { + const response = await axios({ + url: '/therapists?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'therapistsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Therapists')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +TherapistsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default TherapistsTablesPage; diff --git a/frontend/src/pages/therapists/therapists-view.tsx b/frontend/src/pages/therapists/therapists-view.tsx new file mode 100644 index 0000000..3d35135 --- /dev/null +++ b/frontend/src/pages/therapists/therapists-view.tsx @@ -0,0 +1,134 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/therapists/therapistsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const TherapistsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { therapists } = useAppSelector((state) => state.therapists); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View therapists')} + + + + + + +
    +

    User

    + +

    {therapists?.user?.firstName ?? 'No data'}

    +
    + +
    +

    Specialization

    +

    {therapists?.specialization}

    +
    + + <> +

    Sessions Therapist

    + +
    + + + + + + + + + + {therapists.sessions_therapist && + Array.isArray(therapists.sessions_therapist) && + therapists.sessions_therapist.map((item: any) => ( + + router.push( + `/sessions/sessions-view/?id=${item.id}`, + ) + } + > + + + + + ))} + +
    StartTimeEndTime
    + {dataFormatter.dateTimeFormatter(item.start_time)} + + {dataFormatter.dateTimeFormatter(item.end_time)} +
    +
    + {!therapists?.sessions_therapist?.length && ( +
    No data
    + )} +
    + + + + + router.push('/therapists/therapists-list')} + /> +
    +
    + + ); +}; + +TherapistsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default TherapistsView; diff --git a/frontend/src/pages/users/[usersId].tsx b/frontend/src/pages/users/[usersId].tsx new file mode 100644 index 0000000..e16077f --- /dev/null +++ b/frontend/src/pages/users/[usersId].tsx @@ -0,0 +1,205 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditUsers = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + firstName: '', + + lastName: '', + + phoneNumber: '', + + email: '', + + disabled: false, + + avatar: [], + + app_role: null, + + custom_permissions: [], + + password: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { users } = useAppSelector((state) => state.users); + + const { usersId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: usersId })); + }, [usersId]); + + useEffect(() => { + if (typeof users === 'object') { + setInitialValues(users); + } + }, [users]); + + useEffect(() => { + if (typeof users === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = users[el])); + + setInitialValues(newInitialVal); + } + }, [users]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: usersId, data })); + await router.push('/users/users-list'); + }; + + return ( + <> + + {getPageTitle('Edit users')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/users/users-list')} + /> + + +
    +
    +
    + + ); +}; + +EditUsers.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditUsers; diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx new file mode 100644 index 0000000..87d7090 --- /dev/null +++ b/frontend/src/pages/users/users-edit.tsx @@ -0,0 +1,203 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditUsersPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + firstName: '', + + lastName: '', + + phoneNumber: '', + + email: '', + + disabled: false, + + avatar: [], + + app_role: null, + + custom_permissions: [], + + password: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { users } = useAppSelector((state) => state.users); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof users === 'object') { + setInitialValues(users); + } + }, [users]); + + useEffect(() => { + if (typeof users === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = users[el])); + setInitialValues(newInitialVal); + } + }, [users]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/users/users-list'); + }; + + return ( + <> + + {getPageTitle('Edit users')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/users/users-list')} + /> + + +
    +
    +
    + + ); +}; + +EditUsersPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditUsersPage; diff --git a/frontend/src/pages/users/users-list.tsx b/frontend/src/pages/users/users-list.tsx new file mode 100644 index 0000000..6d03ebc --- /dev/null +++ b/frontend/src/pages/users/users-list.tsx @@ -0,0 +1,173 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableUsers from '../../components/Users/TableUsers'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/users/usersSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const UsersTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'First Name', title: 'firstName' }, + { label: 'Last Name', title: 'lastName' }, + { label: 'Phone Number', title: 'phoneNumber' }, + { label: 'E-Mail', title: 'email' }, + + { label: 'App Role', title: 'app_role' }, + + { label: 'Custom Permissions', title: 'custom_permissions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_USERS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getUsersCSV = async () => { + const response = await axios({ + url: '/users?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'usersCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Users')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + + +
    + + + + + ); +}; + +UsersTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default UsersTablesPage; diff --git a/frontend/src/pages/users/users-new.tsx b/frontend/src/pages/users/users-new.tsx new file mode 100644 index 0000000..4f3fa45 --- /dev/null +++ b/frontend/src/pages/users/users-new.tsx @@ -0,0 +1,171 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +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 { 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/users/usersSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + firstName: '', + + lastName: '', + + phoneNumber: '', + + email: '', + + disabled: false, + + avatar: [], + + app_role: '', + + custom_permissions: [], +}; + +const UsersNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/users/users-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/users/users-list')} + /> + + +
    +
    +
    + + ); +}; + +UsersNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default UsersNew; diff --git a/frontend/src/pages/users/users-table.tsx b/frontend/src/pages/users/users-table.tsx new file mode 100644 index 0000000..a408cd6 --- /dev/null +++ b/frontend/src/pages/users/users-table.tsx @@ -0,0 +1,168 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +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 TableUsers from '../../components/Users/TableUsers'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/users/usersSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const UsersTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'First Name', title: 'firstName' }, + { label: 'Last Name', title: 'lastName' }, + { label: 'Phone Number', title: 'phoneNumber' }, + { label: 'E-Mail', title: 'email' }, + + { label: 'App Role', title: 'app_role' }, + + { label: 'Custom Permissions', title: 'custom_permissions' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_USERS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getUsersCSV = async () => { + const response = await axios({ + url: '/users?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'usersCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Users')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + +
    + + + + + ); +}; + +UsersTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default UsersTablesPage; diff --git a/frontend/src/pages/users/users-view.tsx b/frontend/src/pages/users/users-view.tsx new file mode 100644 index 0000000..59ed098 --- /dev/null +++ b/frontend/src/pages/users/users-view.tsx @@ -0,0 +1,304 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/users/usersSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const UsersView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { users } = useAppSelector((state) => state.users); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View users')} + + + + + + +
    +

    First Name

    +

    {users?.firstName}

    +
    + +
    +

    Last Name

    +

    {users?.lastName}

    +
    + +
    +

    Phone Number

    +

    {users?.phoneNumber}

    +
    + +
    +

    E-Mail

    +

    {users?.email}

    +
    + + + null }} + disabled + /> + + +
    +

    Avatar

    + {users?.avatar?.length ? ( + + ) : ( +

    No Avatar

    + )} +
    + +
    +

    App Role

    + +

    {users?.app_role?.name ?? 'No data'}

    +
    + + <> +

    Custom Permissions

    + +
    + + + + + + + + {users.custom_permissions && + Array.isArray(users.custom_permissions) && + users.custom_permissions.map((item: any) => ( + + router.push( + `/permissions/permissions-view/?id=${item.id}`, + ) + } + > + + + ))} + +
    Name
    {item.name}
    +
    + {!users?.custom_permissions?.length && ( +
    No data
    + )} +
    + + + <> +

    Administrators User

    + +
    + + + + + + + + {users.administrators_user && + Array.isArray(users.administrators_user) && + users.administrators_user.map((item: any) => ( + + router.push( + `/administrators/administrators-view/?id=${item.id}`, + ) + } + > + + + ))} + +
    Role
    {item.role}
    +
    + {!users?.administrators_user?.length && ( +
    No data
    + )} +
    + + + <> +

    Clients User

    + +
    + + + + + + + + {users.clients_user && + Array.isArray(users.clients_user) && + users.clients_user.map((item: any) => ( + + router.push(`/clients/clients-view/?id=${item.id}`) + } + > + + + ))} + +
    Mood
    {item.mood}
    +
    + {!users?.clients_user?.length && ( +
    No data
    + )} +
    + + + <> +

    Supervisors User

    + +
    + + + + + + {users.supervisors_user && + Array.isArray(users.supervisors_user) && + users.supervisors_user.map((item: any) => ( + + router.push( + `/supervisors/supervisors-view/?id=${item.id}`, + ) + } + > + ))} + +
    +
    + {!users?.supervisors_user?.length && ( +
    No data
    + )} +
    + + + <> +

    Therapists User

    + +
    + + + + + + + + {users.therapists_user && + Array.isArray(users.therapists_user) && + users.therapists_user.map((item: any) => ( + + router.push( + `/therapists/therapists-view/?id=${item.id}`, + ) + } + > + + + ))} + +
    Specialization
    + {item.specialization} +
    +
    + {!users?.therapists_user?.length && ( +
    No data
    + )} +
    + + + + + router.push('/users/users-list')} + /> +
    +
    + + ); +}; + +UsersView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default UsersView; diff --git a/frontend/src/pages/verify-email.tsx b/frontend/src/pages/verify-email.tsx new file mode 100644 index 0000000..a088720 --- /dev/null +++ b/frontend/src/pages/verify-email.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import Head from 'next/head'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; +import axios from 'axios'; + +export default function Verify() { + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const { token } = router.query; + const notify = (type, msg) => toast(msg, { type }); + + React.useEffect(() => { + if (!token) { + router.push('/login'); + return; + } + const handleSubmit = async () => { + setLoading(true); + await axios + .put('/auth/verify-email', { + token, + }) + .then((verified) => { + if (verified) { + setLoading(false); + notify('success', 'Your email was verified'); + } + }) + .catch((error) => { + setLoading(false); + console.log('error: ', error); + notify('error', error.response); + }) + .finally(async () => { + await router.push('/login'); + }); + }; + handleSubmit().then(); + }, [token]); + + return ( + <> + + {getPageTitle('Verify Email')} + + + +

    {loading ? 'Loading...' : ''}

    +
    +
    + + + + ); +} + +Verify.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/web_pages/about.tsx b/frontend/src/pages/web_pages/about.tsx new file mode 100644 index 0000000..f413725 --- /dev/null +++ b/frontend/src/pages/web_pages/about.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../../stores/hooks'; +import LayoutGuest from '../../layouts/Guest'; +import WebSiteHeader from '../../components/WebPageComponents/Header'; +import WebSiteFooter from '../../components/WebPageComponents/Footer'; +import { + HeroDesigns, + AboutUsDesigns, + FeaturesDesigns, + TestimonialsDesigns, +} from '../../components/WebPageComponents/designs'; + +import HeroSection from '../../components/WebPageComponents/HeroComponent'; + +import AboutUsSection from '../../components/WebPageComponents/AboutUsComponent'; + +import FeaturesSection from '../../components/WebPageComponents/FeaturesComponent'; + +import TestimonialsSection from '../../components/WebPageComponents/TestimonialsComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + const features_points = [ + { + name: 'Personalized Insights', + description: + 'Receive tailored insights based on your data. Understand your mental health patterns and make informed decisions.', + icon: 'mdiChartPie', + }, + { + name: 'Secure Communication', + description: + 'Communicate with therapists securely. Your privacy is our priority, ensuring safe and confidential interactions.', + icon: 'mdiLock', + }, + { + name: 'Resource Library', + description: + 'Access a vast library of self-help resources. From articles to videos, find the support you need at your fingertips.', + icon: 'mdiBookOpenPageVariant', + }, + ]; + + const testimonials = [ + { + text: '${projectName} has been a game-changer for our team. The insights provided have helped us improve our mental health strategies.', + company: 'Wellness Innovators Inc.', + user_name: 'Samantha Green, HR Manager', + }, + { + text: 'The secure communication feature is fantastic. It gives me peace of mind knowing my conversations are private.', + company: 'Mindful Solutions', + user_name: 'John Doe, IT Specialist', + }, + { + text: "I love the resource library. It's packed with valuable information that supports my mental health journey.", + company: 'Health First Group', + user_name: 'Emily White, Wellness Coach', + }, + { + text: '${projectName} has made it easy for me to track my mental health progress. The personalized insights are incredibly helpful.', + company: 'Balance Corp.', + user_name: 'Michael Brown, Data Analyst', + }, + { + text: "The platform's user-friendly design makes it accessible to everyone. It's a must-have tool for mental health support.", + company: 'Harmony Enterprises', + user_name: 'Jessica Smith, Product Designer', + }, + { + text: 'Our team has benefited greatly from ${projectName}. The features are innovative and truly support our mental well-being.', + company: 'Zenith Health', + user_name: 'David Johnson, Team Lead', + }, + ]; + + return ( +
    + + {`About Us - Mental Health Support Platform`} + + + +
    + + + + + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/web_pages/contact.tsx b/frontend/src/pages/web_pages/contact.tsx new file mode 100644 index 0000000..0c5ecee --- /dev/null +++ b/frontend/src/pages/web_pages/contact.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../../stores/hooks'; +import LayoutGuest from '../../layouts/Guest'; +import WebSiteHeader from '../../components/WebPageComponents/Header'; +import WebSiteFooter from '../../components/WebPageComponents/Footer'; +import { + HeroDesigns, + ContactFormDesigns, +} from '../../components/WebPageComponents/designs'; + +import HeroSection from '../../components/WebPageComponents/HeroComponent'; + +import ContactFormSection from '../../components/WebPageComponents/ContactFormComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + return ( +
    + + {`Contact Us - Mental Health Support Platform`} + + + +
    + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/web_pages/faq.tsx b/frontend/src/pages/web_pages/faq.tsx new file mode 100644 index 0000000..ed32274 --- /dev/null +++ b/frontend/src/pages/web_pages/faq.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../../stores/hooks'; +import LayoutGuest from '../../layouts/Guest'; +import WebSiteHeader from '../../components/WebPageComponents/Header'; +import WebSiteFooter from '../../components/WebPageComponents/Footer'; +import { + HeroDesigns, + FaqDesigns, +} from '../../components/WebPageComponents/designs'; + +import HeroSection from '../../components/WebPageComponents/HeroComponent'; + +import FaqSection from '../../components/WebPageComponents/FaqComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + const faqs = [ + { + question: 'What is ${projectName} and how does it work?', + answer: + '${projectName} is a mental health support platform designed to provide users with tools and resources to enhance their mental well-being. It offers features like mood tracking, virtual therapy sessions, and a resource library to support your journey.', + }, + { + question: 'How can I access virtual therapy sessions?', + answer: + "To access virtual therapy sessions, simply log into your ${projectName} account and navigate to the 'Therapy Sessions' section. From there, you can schedule and connect with licensed therapists through secure video calls.", + }, + { + question: 'Is my data secure on ${projectName}?', + answer: + 'Yes, your data is secure on ${projectName}. We use advanced encryption and security protocols to ensure that your personal information and communications remain private and confidential.', + }, + { + question: 'Can I track my mood using ${projectName}?', + answer: + 'Absolutely! ${projectName} offers a real-time mood tracking feature that allows you to monitor your emotional state and receive personalized insights to better understand your mental health patterns.', + }, + { + question: 'What resources are available in the self-help library?', + answer: + 'Our self-help library includes a wide range of resources such as articles, videos, and guided exercises. These resources are designed to support your mental health journey and provide you with valuable information and tools.', + }, + { + question: 'How do I contact support if I have issues?', + answer: + "If you encounter any issues or have questions, you can contact our support team through the 'Contact Us' page on our website. We are here to assist you and will respond promptly to your inquiries.", + }, + ]; + + return ( +
    + + {`Frequently Asked Questions - Mental Health Support Platform`} + + + +
    + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/web_pages/home.tsx b/frontend/src/pages/web_pages/home.tsx new file mode 100644 index 0000000..6240a2f --- /dev/null +++ b/frontend/src/pages/web_pages/home.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../../stores/hooks'; +import LayoutGuest from '../../layouts/Guest'; +import WebSiteHeader from '../../components/WebPageComponents/Header'; +import WebSiteFooter from '../../components/WebPageComponents/Footer'; +import { + HeroDesigns, + FeaturesDesigns, + TestimonialsDesigns, + AboutUsDesigns, + ContactFormDesigns, +} from '../../components/WebPageComponents/designs'; + +import HeroSection from '../../components/WebPageComponents/HeroComponent'; + +import FeaturesSection from '../../components/WebPageComponents/FeaturesComponent'; + +import TestimonialsSection from '../../components/WebPageComponents/TestimonialsComponent'; + +import AboutUsSection from '../../components/WebPageComponents/AboutUsComponent'; + +import ContactFormSection from '../../components/WebPageComponents/ContactFormComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + const features_points = [ + { + name: 'Mood Tracking', + description: + 'Monitor your emotional state daily. Gain insights into patterns and triggers to better understand your mental health journey.', + icon: 'mdiEmoticonHappyOutline', + }, + { + name: 'Guided Meditations', + description: + 'Access a library of calming meditations. Enhance your mindfulness and relaxation with expert-guided sessions.', + icon: 'mdiMeditation', + }, + { + name: 'AI Therapy Chat', + description: + 'Engage in supportive conversations with our AI. Receive instant feedback and guidance tailored to your needs.', + icon: 'mdiRobot', + }, + { + name: 'Community Support', + description: + 'Join a community of like-minded individuals. Share experiences and find encouragement in a safe space.', + icon: 'mdiAccountGroup', + }, + { + name: 'Data Analytics', + description: + 'Visualize your progress with detailed analytics. Make informed decisions about your mental health journey.', + icon: 'mdiChartLine', + }, + { + name: 'Therapist Profiles', + description: + 'Connect with qualified therapists. View profiles and choose the right professional for your needs.', + icon: 'mdiAccountBadgeOutline', + }, + ]; + + const testimonials = [ + { + text: '${projectName} has transformed my mental health journey. The mood tracking feature is a game-changer!', + company: 'Mindful Innovations', + user_name: 'Alex Johnson, Wellness Coordinator', + }, + { + text: 'I love the guided meditations. They help me relax and focus. Highly recommend ${projectName}!', + company: 'ZenTech Solutions', + user_name: 'Emily Carter, Meditation Specialist', + }, + { + text: "The AI therapy chat is like having a therapist in my pocket. It's supportive and insightful.", + company: 'Digital Health Corp', + user_name: 'Michael Lee, Product Manager', + }, + { + text: "Community support on ${projectName} is incredible. I've found a safe space to share and grow.", + company: 'Wellness Connect', + user_name: 'Sarah Thompson, Community Manager', + }, + { + text: "Data analytics on ${projectName} provide valuable insights into my mental health patterns. It's empowering.", + company: 'Insightful Minds', + user_name: 'David Brown, Data Analyst', + }, + { + text: 'Connecting with therapists through ${projectName} was seamless. I found the perfect match for my needs.', + company: 'Therapy Network', + user_name: 'Jessica White, Client Relations', + }, + ]; + + return ( +
    + + {`Mental Health Support Platform - Home`} + + + +
    + + + + + + + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/web_pages/services.tsx b/frontend/src/pages/web_pages/services.tsx new file mode 100644 index 0000000..20636b3 --- /dev/null +++ b/frontend/src/pages/web_pages/services.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useAppSelector } from '../../stores/hooks'; +import LayoutGuest from '../../layouts/Guest'; +import WebSiteHeader from '../../components/WebPageComponents/Header'; +import WebSiteFooter from '../../components/WebPageComponents/Footer'; +import { + HeroDesigns, + FeaturesDesigns, + TestimonialsDesigns, +} from '../../components/WebPageComponents/designs'; + +import HeroSection from '../../components/WebPageComponents/HeroComponent'; + +import FeaturesSection from '../../components/WebPageComponents/FeaturesComponent'; + +import TestimonialsSection from '../../components/WebPageComponents/TestimonialsComponent'; + +export default function WebSite() { + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const projectName = 'mental health wellbing app'; + + useEffect(() => { + const darkElement = document.querySelector('body .dark'); + if (darkElement) { + darkElement.classList.remove('dark'); + } + }, []); + const pages = [ + { + href: '/home', + label: 'home', + }, + + { + href: '/contact', + label: 'contact', + }, + + { + href: '/about', + label: 'about', + }, + + { + href: '/services', + label: 'services', + }, + + { + href: '/faq', + label: 'FAQ', + }, + ]; + + const features_points = [ + { + name: 'Real-Time Mood Analysis', + description: + 'Track your mood in real-time and receive instant feedback. Understand your emotional patterns and make informed decisions.', + icon: 'mdiEmoticonHappyOutline', + }, + { + name: 'Virtual Therapy Sessions', + description: + 'Connect with licensed therapists through secure video calls. Experience personalized therapy from the comfort of your home.', + icon: 'mdiVideo', + }, + { + name: 'Interactive Self-Help Tools', + description: + 'Access a variety of self-help tools designed to support your mental well-being. From guided exercises to mindfulness practices, find what works for you.', + icon: 'mdiTools', + }, + ]; + + const testimonials = [ + { + text: '${projectName} has been a lifeline for our team. The real-time mood analysis is incredibly insightful.', + company: 'Wellness Dynamics', + user_name: 'Anna Thompson, HR Director', + }, + { + text: 'The virtual therapy sessions are a game-changer. I can connect with my therapist anytime, anywhere.', + company: 'Mindful Enterprises', + user_name: 'James Carter, Operations Manager', + }, + { + text: 'Interactive self-help tools have made a significant difference in my daily routine. Highly recommend ${projectName}.', + company: 'Balance Solutions', + user_name: 'Laura Green, Wellness Coach', + }, + { + text: 'Our employees have shown remarkable improvement in mental well-being since using ${projectName}.', + company: 'Zenith Innovations', + user_name: 'Michael Brown, CEO', + }, + { + text: "The platform's user-friendly interface makes it easy for everyone to access the services they need.", + company: 'Harmony Health', + user_name: 'Emily White, Product Designer', + }, + { + text: "${projectName} has transformed the way we approach mental health support. It's a must-have for any organization.", + company: 'Insightful Minds', + user_name: 'David Johnson, Team Lead', + }, + ]; + + return ( +
    + + {`Our Services - Mental Health Support Platform`} + + + +
    + + + + + +
    + +
    + ); +} + +WebSite.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/stores/administrators/administratorsSlice.ts b/frontend/src/stores/administrators/administratorsSlice.ts new file mode 100644 index 0000000..ba033cd --- /dev/null +++ b/frontend/src/stores/administrators/administratorsSlice.ts @@ -0,0 +1,250 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + administrators: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + administrators: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk( + 'administrators/fetch', + async (data: any) => { + const { id, query } = data; + const result = await axios.get( + `administrators${query || (id ? `/${id}` : '')}`, + ); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; + }, +); + +export const deleteItemsByIds = createAsyncThunk( + 'administrators/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('administrators/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'administrators/deleteAdministrators', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`administrators/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'administrators/createAdministrators', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('administrators', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'administrators/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('administrators/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'administrators/updateAdministrators', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`administrators/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const administratorsSlice = createSlice({ + name: 'administrators', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.administrators = action.payload.rows; + state.count = action.payload.count; + } else { + state.administrators = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Administrators has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Administrators'.slice(0, -1)} has been deleted`, + ); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Administrators'.slice(0, -1)} has been created`, + ); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Administrators'.slice(0, -1)} has been updated`, + ); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Administrators has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = administratorsSlice.actions; + +export default administratorsSlice.reducer; diff --git a/frontend/src/stores/authSlice.ts b/frontend/src/stores/authSlice.ts new file mode 100644 index 0000000..1d405da --- /dev/null +++ b/frontend/src/stores/authSlice.ts @@ -0,0 +1,125 @@ +import { createSlice, createAsyncThunk, createAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import jwt from 'jsonwebtoken'; + +interface MainState { + isFetching: boolean; + errorMessage: string; + currentUser: any; + notify: any; + token: string; +} + +const initialState: MainState = { + /* User */ + isFetching: false, + errorMessage: '', + currentUser: null, + token: '', + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const resetAction = createAction('auth/passwordReset/reset'); + +export const loginUser = createAsyncThunk( + 'auth/loginUser', + async (creds: Record, { rejectWithValue }) => { + try { + const response = await axios.post('auth/signin/local', creds); + return response.data; + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + }, +); + +export const passwordReset = createAsyncThunk( + 'auth/passwordReset', + async (value: Record, { rejectWithValue }) => { + try { + const { data: response } = await axios.put('/auth/password-reset', { + token: value.token, + password: value.password, + type: value.type, + }); + + return response.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const findMe = createAsyncThunk('auth/findMe', async () => { + const response = await axios.get('auth/me'); + return response.data; +}); + +export const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + logoutUser: (state) => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + axios.defaults.headers.common['Authorization'] = ''; + state.currentUser = null; + state.token = ''; + }, + }, + extraReducers: (builder) => { + builder.addCase(loginUser.pending, (state) => { + state.isFetching = true; + }); + builder.addCase(loginUser.fulfilled, (state, action) => { + const token = action.payload; + const user = jwt.decode(token); + + state.errorMessage = ''; + state.token = token; + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(user)); + axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; + }); + + builder.addCase(loginUser.rejected, (state, action) => { + state.errorMessage = + String(action.payload) || 'Something went wrong. Try again'; + state.isFetching = false; + }); + builder.addCase(findMe.pending, () => { + console.log('Pending findMe'); + }); + builder.addCase(findMe.fulfilled, (state, action) => { + state.currentUser = action.payload; + state.isFetching = false; + }); + + builder.addCase(passwordReset.fulfilled, (state, action) => { + state.notify.showNotification = true; + state.notify.textNotification = 'Password has been reset successfully'; + }); + + builder.addCase(resetAction, (state) => initialState); + + builder.addCase(passwordReset.rejected, (state) => { + state.errorMessage = 'Something was wrong. Try again'; + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { logoutUser } = authSlice.actions; + +export default authSlice.reducer; diff --git a/frontend/src/stores/clients/clientsSlice.ts b/frontend/src/stores/clients/clientsSlice.ts new file mode 100644 index 0000000..5d447fd --- /dev/null +++ b/frontend/src/stores/clients/clientsSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + clients: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + clients: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('clients/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`clients${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'clients/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('clients/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'clients/deleteClients', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`clients/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'clients/createClients', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('clients', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'clients/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('clients/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'clients/updateClients', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`clients/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const clientsSlice = createSlice({ + name: 'clients', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.clients = action.payload.rows; + state.count = action.payload.count; + } else { + state.clients = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Clients has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Clients'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Clients'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Clients'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Clients has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = clientsSlice.actions; + +export default clientsSlice.reducer; diff --git a/frontend/src/stores/hooks.ts b/frontend/src/stores/hooks.ts new file mode 100644 index 0000000..beb95c0 --- /dev/null +++ b/frontend/src/stores/hooks.ts @@ -0,0 +1,6 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/frontend/src/stores/introSteps.ts b/frontend/src/stores/introSteps.ts new file mode 100644 index 0000000..bb91ae3 --- /dev/null +++ b/frontend/src/stores/introSteps.ts @@ -0,0 +1,152 @@ +interface Step { + element: string; + intro: string; + position?: string; + tooltipClass?: string; + highlightClass?: string; + disableInteraction?: boolean; +} + +interface Hint { + element: string; + hint: string; + hintPosition?: string; +} + +export const landingSteps: Step[] = [ + { + element: '#elementId1', + intro: ` +
    + Description +

    Welcome to our app tutorial! Get a sneak peek into the key functionalities and learn how to navigate seamlessly. Here's a quick overview to get you started.

    +
    + `, + position: 'auto', + tooltipClass: ' good-img', + }, + { + element: '#websiteHeader', + intro: + "You can switch between different sections of the app using this header. It's your gateway to exploring all the available pages.", + position: 'auto', + tooltipClass: ' right-0 mx-auto rounded shadow-lg', + disableInteraction: true, + }, + { + element: '#loginButton', + intro: + 'Decide whether to explore the landing page or proceed to the login. You can always return to the landing page later.', + disableInteraction: true, + }, +]; + +export const loginSteps: Step[] = [ + { + element: '#loginRoles', + intro: + 'Choose your login role to proceed. Experience the app as Admin, or User, or create your own account to get started.', + position: 'auto', + }, +]; + +export const appSteps: Step[] = [ + { + element: '#profilEdit', + intro: + "Update your profile information, including name, email, and password. Don't forget to save your changes to keep your profile current.", + position: 'auto', + disableInteraction: true, + }, + { + element: '#themeToggle', + intro: 'Switch between light and dark modes to suit your preference.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#logout', + intro: 'Log out or switch users/roles with ease to manage your access.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#search', + intro: + 'Quickly find specific data or items by entering your query in the search field. Navigate directly to the desired element.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#widgetCreator', + intro: + 'Use Text-to-Chart and Text-to-Widget to create charts or widgets from text descriptions. Type what you need, like "Orders by Month," and customize your dashboard.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#dashboard', + intro: + 'View all the entities available to your role, offering insights into the data categories and total items in each.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#asideMenu', + intro: + 'Access various entities and manage your data. Find links to the landing page and Swagger API documentation for more information.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#asideMenu', + intro: "Let's explore the User entity.", + position: 'auto', + disableInteraction: true, + }, +]; + +export const usersSteps: Step[] = [ + { + element: '#usersList', + intro: + 'Invite new users, filter data, and work with CSV files in this section.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#usersTable', + intro: + 'View, modify, or delete items with the necessary permissions. Inline editing is available within the table.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#asideMenu', + intro: "Let's explore the Roles entity.", + position: 'auto', + disableInteraction: true, + }, +]; + +export const rolesSteps: Step[] = [ + { + element: '#rolesTable', + intro: + 'Super Admin can manage roles and permissions. Adjust access levels and permissions for each role or user in the Roles and Permissions sections.', + position: 'auto', + disableInteraction: true, + }, + { + element: '#feedbackSection', + intro: ` +
    + Description +

    Thank you for completing the tour! We hope you now have a better understanding of the app.

    +

    If you have any questions, feel free to reach out to us at support@flatlogic.com.

    +
    + `, + position: 'auto', + tooltipClass: 'end-img', + }, +]; diff --git a/frontend/src/stores/mainSlice.ts b/frontend/src/stores/mainSlice.ts new file mode 100644 index 0000000..92b8935 --- /dev/null +++ b/frontend/src/stores/mainSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { UserPayloadObject } from '../interfaces'; + +interface MainState { + userName: string; + userEmail: null | string; + userAvatar: null | string; + isFieldFocusRegistered: boolean; +} + +const initialState: MainState = { + /* User */ + userName: '', + userEmail: null, + userAvatar: null, + + /* Field focus with ctrl+k (to register only once) */ + isFieldFocusRegistered: false, +}; + +export const mainSlice = createSlice({ + name: 'main', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.userName = action.payload.name; + state.userEmail = action.payload.email; + state.userAvatar = action.payload.avatar; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { setUser } = mainSlice.actions; + +export default mainSlice.reducer; diff --git a/frontend/src/stores/openAiSlice.ts b/frontend/src/stores/openAiSlice.ts new file mode 100644 index 0000000..7299beb --- /dev/null +++ b/frontend/src/stores/openAiSlice.ts @@ -0,0 +1,79 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import axios from 'axios'; + +interface MainState { + isFetchingQuery: boolean; + errorMessage: string; + smartWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} +const initialState: MainState = { + isFetchingQuery: false, + errorMessage: '', + smartWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +const fulfilledNotify = (state, msg, type?: string) => { + state.notify.textNotification = msg; + state.notify.typeNotification = type || 'success'; + state.notify.showNotification = true; +}; + +export const aiPrompt = createAsyncThunk( + 'openai/aiPrompt', + async (data: any, { rejectWithValue }) => { + try { + return await axios.post('/openai/create_widget', data); + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + }, +); + +export const openAiSlice = createSlice({ + name: 'openAiSlice', + initialState, + reducers: { + resetNotify: (state) => { + state.notify.showNotification = false; + state.notify.typeNotification = ''; + state.notify.textNotification = ''; + }, + setErrorNotification: (state, action) => { + fulfilledNotify(state, action.payload, 'error'); + }, + }, + extraReducers: (builder) => { + builder.addCase(aiPrompt.pending, (state) => { + state.isFetchingQuery = true; + }); + builder.addCase(aiPrompt.fulfilled, (state, action: Record) => { + state.isFetchingQuery = false; + state.errorMessage = ''; + state.smartWidgets.unshift(action.payload.data); + }); + + builder.addCase(aiPrompt.rejected, (state) => { + state.errorMessage = 'Something was wrong. Try again'; + state.isFetchingQuery = false; + state.smartWidgets = null; + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { resetNotify, setErrorNotification } = openAiSlice.actions; + +export default openAiSlice.reducer; diff --git a/frontend/src/stores/permissions/permissionsSlice.ts b/frontend/src/stores/permissions/permissionsSlice.ts new file mode 100644 index 0000000..11b8224 --- /dev/null +++ b/frontend/src/stores/permissions/permissionsSlice.ts @@ -0,0 +1,241 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + permissions: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + permissions: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk( + 'permissions/fetch', + async (data: any) => { + const { id, query } = data; + const result = await axios.get( + `permissions${query || (id ? `/${id}` : '')}`, + ); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; + }, +); + +export const deleteItemsByIds = createAsyncThunk( + 'permissions/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('permissions/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'permissions/deletePermissions', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`permissions/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'permissions/createPermissions', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('permissions', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'permissions/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('permissions/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'permissions/updatePermissions', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`permissions/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const permissionsSlice = createSlice({ + name: 'permissions', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.permissions = action.payload.rows; + state.count = action.payload.count; + } else { + state.permissions = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Permissions has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Permissions'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Permissions'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Permissions'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Permissions has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = permissionsSlice.actions; + +export default permissionsSlice.reducer; diff --git a/frontend/src/stores/resources/resourcesSlice.ts b/frontend/src/stores/resources/resourcesSlice.ts new file mode 100644 index 0000000..9a86dd1 --- /dev/null +++ b/frontend/src/stores/resources/resourcesSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + resources: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + resources: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('resources/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`resources${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'resources/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('resources/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'resources/deleteResources', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`resources/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'resources/createResources', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('resources', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'resources/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('resources/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'resources/updateResources', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`resources/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const resourcesSlice = createSlice({ + name: 'resources', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.resources = action.payload.rows; + state.count = action.payload.count; + } else { + state.resources = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Resources has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Resources'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Resources'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Resources'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Resources has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = resourcesSlice.actions; + +export default resourcesSlice.reducer; diff --git a/frontend/src/stores/roles/rolesSlice.ts b/frontend/src/stores/roles/rolesSlice.ts new file mode 100644 index 0000000..177e83b --- /dev/null +++ b/frontend/src/stores/roles/rolesSlice.ts @@ -0,0 +1,283 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + roles: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + roles: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('roles/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`roles${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'roles/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('roles/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'roles/deleteRoles', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`roles/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'roles/createRoles', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('roles', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'roles/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('roles/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'roles/updateRoles', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`roles/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const removeWidget = createAsyncThunk( + 'openai/removeWidget', + async (payload: any) => { + const result = await axios.delete(`openai/roles-info/${payload.id}`, { + params: { + roleId: payload.roleId, + infoId: payload.widgetId, + key: 'widgets', + }, + }); + return result.data; + }, +); + +export const fetchWidgets = createAsyncThunk( + 'openai/fetchWidgets', + async (roleId: any) => { + const result = await axios.get( + `openai/info-by-key?key=widgets&roleId=${roleId}`, + ); + return result.data; + }, +); + +export const rolesSlice = createSlice({ + name: 'roles', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.roles = action.payload.rows; + state.count = action.payload.count; + } else { + state.roles = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Roles has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Roles'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Roles'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Roles'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Roles has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(removeWidget.pending, (state) => { + state.loading = true; + }); + builder.addCase(removeWidget.fulfilled, (state) => { + state.loading = false; + }); + builder.addCase(removeWidget.rejected, (state) => { + state.loading = false; + }); + + builder.addCase(fetchWidgets.pending, (state) => { + state.loading = true; + state.rolesWidgets = []; + }); + builder.addCase(fetchWidgets.fulfilled, (state, action) => { + state.loading = false; + state.rolesWidgets = action.payload; + }); + builder.addCase(fetchWidgets.rejected, (state) => { + state.loading = false; + state.rolesWidgets = []; + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = rolesSlice.actions; + +export default rolesSlice.reducer; diff --git a/frontend/src/stores/sessions/sessionsSlice.ts b/frontend/src/stores/sessions/sessionsSlice.ts new file mode 100644 index 0000000..ea3b7be --- /dev/null +++ b/frontend/src/stores/sessions/sessionsSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + sessions: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + sessions: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('sessions/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`sessions${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'sessions/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('sessions/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'sessions/deleteSessions', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`sessions/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'sessions/createSessions', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('sessions', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'sessions/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('sessions/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'sessions/updateSessions', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`sessions/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const sessionsSlice = createSlice({ + name: 'sessions', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.sessions = action.payload.rows; + state.count = action.payload.count; + } else { + state.sessions = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Sessions has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Sessions'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Sessions'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Sessions'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Sessions has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = sessionsSlice.actions; + +export default sessionsSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts new file mode 100644 index 0000000..a512227 --- /dev/null +++ b/frontend/src/stores/store.ts @@ -0,0 +1,39 @@ +import { configureStore } from '@reduxjs/toolkit'; +import styleReducer from './styleSlice'; +import mainReducer from './mainSlice'; +import authSlice from './authSlice'; +import openAiSlice from './openAiSlice'; + +import usersSlice from './users/usersSlice'; +import administratorsSlice from './administrators/administratorsSlice'; +import clientsSlice from './clients/clientsSlice'; +import resourcesSlice from './resources/resourcesSlice'; +import sessionsSlice from './sessions/sessionsSlice'; +import supervisorsSlice from './supervisors/supervisorsSlice'; +import therapistsSlice from './therapists/therapistsSlice'; +import rolesSlice from './roles/rolesSlice'; +import permissionsSlice from './permissions/permissionsSlice'; + +export const store = configureStore({ + reducer: { + style: styleReducer, + main: mainReducer, + auth: authSlice, + openAi: openAiSlice, + + users: usersSlice, + administrators: administratorsSlice, + clients: clientsSlice, + resources: resourcesSlice, + sessions: sessionsSlice, + supervisors: supervisorsSlice, + therapists: therapistsSlice, + roles: rolesSlice, + permissions: permissionsSlice, + }, +}); + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/stores/styleSlice.ts b/frontend/src/stores/styleSlice.ts new file mode 100644 index 0000000..45f6c9d --- /dev/null +++ b/frontend/src/stores/styleSlice.ts @@ -0,0 +1,108 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import * as styles from '../styles'; +import { localStorageDarkModeKey, localStorageStyleKey } from '../config'; +import { StyleKey } from '../interfaces'; + +interface StyleState { + asideStyle: string; + asideScrollbarsStyle: string; + asideBrandStyle: string; + asideMenuItemStyle: string; + asideMenuItemActiveStyle: string; + asideMenuDropdownStyle: string; + navBarItemLabelStyle: string; + navBarItemLabelHoverStyle: string; + navBarItemLabelActiveColorStyle: string; + overlayStyle: string; + darkMode: boolean; + bgLayoutColor: string; + iconsColor: string; + activeLinkColor: string; + cardsColor: string; + focusRingColor: string; + corners: string; + cardsStyle: string; + linkColor: string; + websiteHeder: string; + borders: string; + shadow: string; + websiteSectionStyle: string; + textSecondary: string; +} + +const initialState: StyleState = { + asideStyle: styles.pastelEmeraldTheme.aside, + asideScrollbarsStyle: styles.white.asideScrollbars, + asideBrandStyle: styles.white.asideBrand, + asideMenuItemStyle: styles.pastelEmeraldTheme.asideMenuItem, + asideMenuItemActiveStyle: styles.pastelEmeraldTheme.asideMenuItemActive, + activeLinkColor: styles.pastelEmeraldTheme.activeLinkColor, + asideMenuDropdownStyle: styles.white.asideMenuDropdown, + navBarItemLabelStyle: styles.pastelEmeraldTheme.navBarItemLabel, + navBarItemLabelHoverStyle: styles.pastelEmeraldTheme.navBarItemLabelHover, + navBarItemLabelActiveColorStyle: + styles.pastelEmeraldTheme.navBarItemLabelActiveColor, + overlayStyle: styles.pastelEmeraldTheme.overlay, + darkMode: false, + bgLayoutColor: styles.pastelEmeraldTheme.bgLayoutColor, + iconsColor: styles.pastelEmeraldTheme.iconsColor, + cardsColor: styles.pastelEmeraldTheme.cardsColor, + focusRingColor: styles.pastelEmeraldTheme.focusRingColor, + corners: styles.pastelEmeraldTheme.corners, + cardsStyle: styles.pastelEmeraldTheme.cardsStyle, + linkColor: styles.pastelEmeraldTheme.linkColor, + websiteHeder: styles.pastelEmeraldTheme.websiteHeder, + borders: styles.pastelEmeraldTheme.borders, + shadow: styles.pastelEmeraldTheme.shadow, + websiteSectionStyle: styles.pastelEmeraldTheme.websiteSectionStyle, + textSecondary: styles.pastelEmeraldTheme.textSecondary, +}; + +export const styleSlice = createSlice({ + name: 'style', + initialState, + reducers: { + setDarkMode: (state, action: PayloadAction) => { + state.darkMode = + action.payload !== null ? action.payload : !state.darkMode; + + if (typeof localStorage !== 'undefined') { + localStorage.setItem( + localStorageDarkModeKey, + state.darkMode ? '1' : '0', + ); + } + + if (typeof document !== 'undefined') { + document.body.classList[state.darkMode ? 'add' : 'remove']( + 'dark-scrollbars', + ); + + document.documentElement.classList[state.darkMode ? 'add' : 'remove']( + 'dark-scrollbars-compat', + ); + } + }, + + setStyle: (state, action: PayloadAction) => { + if (!styles[action.payload]) { + return; + } + + if (typeof localStorage !== 'undefined') { + localStorage.setItem(localStorageStyleKey, action.payload); + } + + const style = styles[action.payload]; + + for (const key in style) { + state[`${key}Style`] = style[key]; + } + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { setDarkMode, setStyle } = styleSlice.actions; + +export default styleSlice.reducer; diff --git a/frontend/src/stores/supervisors/supervisorsSlice.ts b/frontend/src/stores/supervisors/supervisorsSlice.ts new file mode 100644 index 0000000..60c49bf --- /dev/null +++ b/frontend/src/stores/supervisors/supervisorsSlice.ts @@ -0,0 +1,241 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + supervisors: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + supervisors: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk( + 'supervisors/fetch', + async (data: any) => { + const { id, query } = data; + const result = await axios.get( + `supervisors${query || (id ? `/${id}` : '')}`, + ); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; + }, +); + +export const deleteItemsByIds = createAsyncThunk( + 'supervisors/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('supervisors/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'supervisors/deleteSupervisors', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`supervisors/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'supervisors/createSupervisors', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('supervisors', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'supervisors/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('supervisors/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'supervisors/updateSupervisors', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`supervisors/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const supervisorsSlice = createSlice({ + name: 'supervisors', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.supervisors = action.payload.rows; + state.count = action.payload.count; + } else { + state.supervisors = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Supervisors has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Supervisors'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Supervisors'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Supervisors'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Supervisors has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = supervisorsSlice.actions; + +export default supervisorsSlice.reducer; diff --git a/frontend/src/stores/therapists/therapistsSlice.ts b/frontend/src/stores/therapists/therapistsSlice.ts new file mode 100644 index 0000000..ad568bb --- /dev/null +++ b/frontend/src/stores/therapists/therapistsSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + therapists: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + therapists: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('therapists/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`therapists${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'therapists/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('therapists/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'therapists/deleteTherapists', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`therapists/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'therapists/createTherapists', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('therapists', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'therapists/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('therapists/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'therapists/updateTherapists', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`therapists/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const therapistsSlice = createSlice({ + name: 'therapists', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.therapists = action.payload.rows; + state.count = action.payload.count; + } else { + state.therapists = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Therapists has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Therapists'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Therapists'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Therapists'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Therapists has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = therapistsSlice.actions; + +export default therapistsSlice.reducer; diff --git a/frontend/src/stores/users/usersSlice.ts b/frontend/src/stores/users/usersSlice.ts new file mode 100644 index 0000000..f182315 --- /dev/null +++ b/frontend/src/stores/users/usersSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + users: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + users: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('users/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`users${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'users/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('users/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'users/deleteUsers', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`users/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'users/createUsers', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('users', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'users/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('users/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'users/updateUsers', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`users/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.users = action.payload.rows; + state.count = action.payload.count; + } else { + state.users = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Users has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Users'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Users'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Users'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Users has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = usersSlice.actions; + +export default usersSlice.reducer; diff --git a/frontend/src/stores/usersSlice.ts b/frontend/src/stores/usersSlice.ts new file mode 100644 index 0000000..aa7f5be --- /dev/null +++ b/frontend/src/stores/usersSlice.ts @@ -0,0 +1,119 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; + +interface MainState { + users: any; + loading: boolean; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + users: [], + loading: false, + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('users/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`users${query || (id ? `/${id}` : '')}`); + return id ? result.data : result.data.rows; +}); + +export const deleteItem = createAsyncThunk( + 'users/deleteUser', + async (id: string, thunkAPI) => { + try { + await axios.delete(`users/${id}`); + thunkAPI.dispatch(fetch({ id: '', query: '' })); + } catch (error) { + console.log(error); + } + + // showNotification('Users has been deleted', 'success'); + }, +); + +export const create = createAsyncThunk( + 'users/createUser', + async (data: any) => { + const result = await axios.post('users', { data }); + // showNotification('Users has been created', 'success'); + return result.data; + }, +); + +export const update = createAsyncThunk( + 'users/updateUser', + async (payload: any) => { + const result = await axios.put(`users/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + }, +); + +export const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + }); + builder.addCase(fetch.rejected, (state) => { + state.loading = false; + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + state.users = action.payload; + state.loading = false; + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + }); + + builder.addCase(deleteItem.rejected, (state) => { + state.loading = false; + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + }); + builder.addCase(create.rejected, (state) => { + state.loading = false; + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + }); + builder.addCase(update.rejected, (state) => { + state.loading = false; + }); + }, +}); + +// Action creators are generated for each case reducer function +// export const { } = usersSlice.actions + +export default usersSlice.reducer; diff --git a/frontend/src/styles.ts b/frontend/src/styles.ts new file mode 100644 index 0000000..33c5ba8 --- /dev/null +++ b/frontend/src/styles.ts @@ -0,0 +1,135 @@ +interface StyleObject { + aside: string; + asideScrollbars: string; + asideBrand: string; + asideMenuItem: string; + asideMenuItemActive: string; + asideMenuDropdown: string; + navBarItemLabel: string; + navBarItemLabelHover: string; + navBarItemLabelActiveColor: string; + overlay: string; + activeLinkColor: string; + bgLayoutColor: string; + iconsColor: string; + cardsColor: string; + focusRingColor: string; + corners: string; + cardsStyle: string; + linkColor: string; + websiteHeder: string; + borders: string; + shadow: string; + websiteSectionStyle: string; + textSecondary: string; +} + +export const basic: StyleObject = { + aside: 'bg-gray-800 lg:rounded-2xl', + asideScrollbars: 'aside-scrollbars-gray', + asideBrand: 'bg-gray-900 text-white', + asideMenuItem: 'text-gray-300 hover:text-white', + asideMenuItemActive: 'font-bold text-white', + asideMenuDropdown: 'bg-gray-700/50', + navBarItemLabel: 'text-black', + navBarItemLabelHover: 'hover:text-blue-500', + navBarItemLabelActiveColor: 'text-blue-600', + overlay: 'from-gray-700 via-gray-900 to-gray-700', + activeLinkColor: 'bg-gray-100/70', + bgLayoutColor: 'bg-gray-50', + iconsColor: 'text-blue-500', + cardsColor: 'bg-white', + focusRingColor: + 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600', + corners: 'rounded', + cardsStyle: 'bg-white border border-pavitra-400', + linkColor: 'text-black', + websiteHeder: '', + borders: '', + shadow: '', + websiteSectionStyle: '', + textSecondary: '', +}; + +export const white: StyleObject = { + aside: 'bg-white dark:text-white lg:rounded-2xl', + asideScrollbars: 'aside-scrollbars-light', + asideBrand: '', + asideMenuItem: + 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', + asideMenuItemActive: 'font-bold text-black dark:text-white', + asideMenuDropdown: 'bg-gray-100/75', + navBarItemLabel: 'text-blue-600', + navBarItemLabelHover: 'hover:text-black', + navBarItemLabelActiveColor: 'text-black', + overlay: 'from-white via-gray-100 to-white', + activeLinkColor: 'bg-gray-100/70', + bgLayoutColor: 'bg-gray-50', + iconsColor: 'text-blue-500', + cardsColor: 'bg-white', + focusRingColor: + 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600', + corners: 'rounded', + cardsStyle: 'bg-white border border-pavitra-400', + linkColor: 'text-blue-600', + websiteHeder: 'border-b border-gray-200', + borders: 'border-gray-200', + shadow: '', + websiteSectionStyle: '', + textSecondary: 'text-gray-500', +}; + +export const pastelEmeraldTheme: StyleObject = { + aside: + 'bg-pastelEmeraldTheme-800 text-pastelEmeraldTheme-text dark:text-white lg:rounded-lg', + asideScrollbars: 'aside-scrollbars-blue', + asideBrand: 'text-blue-500 bg-white', + asideMenuItem: + 'text-pastelEmeraldTheme-text hover:text-white dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800 dark:text-white', + asideMenuItemActive: 'font-bold text-white dark:text-white', + activeLinkColor: 'bg-pastelEmeraldTheme-buttonColor rounded-lg', + asideMenuDropdown: 'bg-blue-700/50', + navBarItemLabel: 'text-primaryText', + iconsColor: 'text-pastelEmeraldTheme-iconsColor dark:text-blue-500', + navBarItemLabelHover: 'hover:text-stone-400', + navBarItemLabelActiveColor: 'text-pastelEmeraldTheme-800', + overlay: 'bg-pastelEmeraldTheme-mainBG', + bgLayoutColor: 'bg-pastelEmeraldTheme-mainBG', + cardsColor: 'bg-pastelEmeraldTheme-cardColor', + focusRingColor: + 'focus:ring focus:ring-pastelEmeraldTheme-800 focus:border-pastelEmeraldTheme-800 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600', + corners: 'rounded-lg', + cardsStyle: 'bg-pastelEmeraldTheme-outsideCardColor ', + linkColor: 'text-pastelEmeraldTheme-buttonColor', + websiteHeder: 'border-b border-gray-900 border-opacity-10', + borders: 'border-gray-500 border-opacity-10', + shadow: '', + websiteSectionStyle: ' bg-pastelEmeraldTheme-webSiteComponentBg text-white', + textSecondary: 'text-slate-700', +}; + +export const dataGridStyles = { + '& .MuiDataGrid-cell': { + paddingX: 3, + border: 'none', + }, + '& .MuiDataGrid-columnHeader': { + paddingX: 3, + }, + '& .MuiDataGrid-columnHeaderCheckbox': { + paddingX: 0, + }, + '& .MuiDataGrid-columnHeaders': { + paddingY: 4, + borderStartStartRadius: 7, + borderStartEndRadius: 7, + }, + '& .MuiDataGrid-footerContainer': { + paddingY: 0.5, + borderEndStartRadius: 7, + borderEndEndRadius: 7, + }, + '& .MuiDataGrid-root': { + border: 'none', + }, +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..18b162f --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,127 @@ +/* eslint-env node */ +/* eslint-disable-next-line */ +const plugin = require('tailwindcss/plugin'); + +module.exports = { + content: ['./src/**/*.{js,ts,jsx,tsx}'], + darkMode: 'class', // or 'media' or 'class' + theme: { + asideScrollbars: { + light: 'light', + gray: 'gray', + }, + extend: { + zIndex: { + '-1': '-1', + }, + flexGrow: { + 5: '5', + }, + maxHeight: { + 'screen-menu': 'calc(100vh - 3.5rem)', + modal: 'calc(100vh - 160px)', + }, + transitionProperty: { + position: 'right, left, top, bottom, margin, padding', + textColor: 'color', + }, + keyframes: { + 'fade-out': { + from: { opacity: 1 }, + to: { opacity: 0 }, + }, + 'fade-in': { + from: { opacity: 0 }, + to: { opacity: 1 }, + }, + }, + animation: { + 'fade-out': 'fade-out 250ms ease-in-out', + 'fade-in': 'fade-in 250ms ease-in-out', + }, + colors: { + dark: { + 900: '#131618', + 800: '#21242A', + 700: '#2C2F36', + 600: '#9CA3AF', + 500: '#CBD5E1', + }, + green: { + text: '#45B26B', + }, + pavitra: { + blue: '#0162FD', + green: '#00B448', + orange: '#FFAA00', + red: '#F20041', + 900: '#14142A', + 800: '#4E4B66', + 700: '#6E7191', + 600: '#A0A3BD', + 500: '#D9DBE9', + 400: '#EFF0F6', + 300: '#F7F7FC', + }, + + pastelEmeraldTheme: { + text: '#515564', + iconsColor: '#030A0D', + mainBG: '#DFECF2', + buttonColor: '#030A0D', + outsideCardColor: '#D0E1E9', + cardColor: '#DFECF2', + webSiteComponentBg: '#030A0D', + testimonials: '#FFFFFF', + pricing: '#D0E1E9', + diversityContact: ',#D0E1E9', + diversityMain: '#D0E1E9', + diversityHeader: '#D0E1E9', + 900: '#14142A', + 800: '#D0E1E9', + }, + primaryText: '#030A0D', + }, + fontFamily: { + sans: ['Nunito Sans', 'sans-serif'], + }, + borderRadius: { + '3xl': '2rem', + }, + }, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + plugin(function ({ matchUtilities, theme }) { + matchUtilities( + { + 'aside-scrollbars': (value) => { + const track = value === 'light' ? '100' : '900'; + const thumb = value === 'light' ? '300' : '600'; + const color = value === 'light' ? 'gray' : value; + + return { + scrollbarWidth: 'thin', + scrollbarColor: `${theme(`colors.${color}.${thumb}`)} ${theme( + `colors.${color}.${track}`, + )}`, + '&::-webkit-scrollbar': { + width: '8px', + height: '8px', + }, + '&::-webkit-scrollbar-track': { + backgroundColor: theme(`colors.${color}.${track}`), + }, + '&::-webkit-scrollbar-thumb': { + borderRadius: '0.25rem', + backgroundColor: theme(`colors.${color}.${thumb}`), + }, + }; + }, + }, + { values: theme('asideScrollbars') }, + ); + }), + ], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b8d5978 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 0000000..5c2e352 --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,3608 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.2": + version "7.26.2" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/generator@^7.26.3": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz" + integrity sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ== + dependencies: + "@babel/parser" "^7.26.3" + "@babel/types" "^7.26.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/helper-module-imports@^7.16.7": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz" + integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== + dependencies: + "@babel/traverse" "^7.25.9" + "@babel/types" "^7.25.9" + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/parser@^7.25.9", "@babel/parser@^7.26.3": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz" + integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== + dependencies: + "@babel/types" "^7.26.3" + +"@babel/runtime-corejs3@^7.10.2": + version "7.19.0" + resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.19.0.tgz" + integrity sha512-JyXXoCu1N8GLuKc2ii8y5RGma5FMpFeO2nAQIe0Yzrbq+rQnN+sFj47auLblR5ka6aHNGPDgv8G/iI2Grb0ldQ== + dependencies: + core-js-pure "^3.20.2" + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.26.0" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/template@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz" + integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg== + dependencies: + "@babel/code-frame" "^7.25.9" + "@babel/parser" "^7.25.9" + "@babel/types" "^7.25.9" + +"@babel/traverse@^7.25.9": + version "7.26.4" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz" + integrity sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.3" + "@babel/parser" "^7.26.3" + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.3" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.25.9", "@babel/types@^7.26.3": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz" + integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@emotion/babel-plugin@^11.13.5": + version "11.13.5" + resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" + integrity sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/serialize" "^1.3.3" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + +"@emotion/cache@^11.13.5", "@emotion/cache@^11.14.0", "@emotion/cache@^11.4.0": + version "11.14.0" + resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz" + integrity sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA== + dependencies: + "@emotion/memoize" "^0.9.0" + "@emotion/sheet" "^1.4.0" + "@emotion/utils" "^1.4.2" + "@emotion/weak-memoize" "^0.4.0" + stylis "4.2.0" + +"@emotion/hash@^0.9.2": + version "0.9.2" + resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz" + integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== + +"@emotion/is-prop-valid@^1.3.0": + version "1.3.1" + resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz" + integrity sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw== + dependencies: + "@emotion/memoize" "^0.9.0" + +"@emotion/memoize@^0.9.0": + version "0.9.0" + resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" + integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== + +"@emotion/react@^11.11.3", "@emotion/react@^11.8.1": + version "11.14.0" + resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" + integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.13.5" + "@emotion/cache" "^11.14.0" + "@emotion/serialize" "^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.2.0" + "@emotion/utils" "^1.4.2" + "@emotion/weak-memoize" "^0.4.0" + hoist-non-react-statics "^3.3.1" + +"@emotion/serialize@^1.3.3": + version "1.3.3" + resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz" + integrity sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA== + dependencies: + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/unitless" "^0.10.0" + "@emotion/utils" "^1.4.2" + csstype "^3.0.2" + +"@emotion/sheet@^1.4.0": + version "1.4.0" + resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" + integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== + +"@emotion/styled@^11.11.0": + version "11.14.0" + resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz" + integrity sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.13.5" + "@emotion/is-prop-valid" "^1.3.0" + "@emotion/serialize" "^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.2.0" + "@emotion/utils" "^1.4.2" + +"@emotion/unitless@^0.10.0": + version "0.10.0" + resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz" + integrity sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg== + +"@emotion/use-insertion-effect-with-fallbacks@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz" + integrity sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg== + +"@emotion/utils@^1.4.2": + version "1.4.2" + resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz" + integrity sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA== + +"@emotion/weak-memoize@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" + integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== + +"@eslint/eslintrc@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz" + integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@floating-ui/core@^1.6.0": + version "1.6.8" + resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz" + integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA== + dependencies: + "@floating-ui/utils" "^0.2.8" + +"@floating-ui/dom@^1.0.1": + version "1.6.12" + resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz" + integrity sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.8" + +"@floating-ui/utils@^0.2.8": + version "0.2.8" + resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz" + integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== + +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + +"@mdi/js@^7.4.47": + version "7.4.47" + resolved "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz" + integrity sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ== + +"@mui/core-downloads-tracker@^5.16.13": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.13.tgz" + integrity sha512-xe5RwI0Q2O709Bd2Y7l1W1NIwNmln0y+xaGk5VgX3vDJbkQEqzdfTFZ73e0CkEZgJwyiWgk5HY0l8R4nysOxjw== + +"@mui/material@^5.15.7": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/material/-/material-5.16.13.tgz" + integrity sha512-FhLDkDPYDzvrWCHFsdXzRArhS4AdYufU8d69rmLL+bwhodPcbm2C7cS8Gq5VR32PsW6aKZb58gvAgvEVaiiJbA== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/core-downloads-tracker" "^5.16.13" + "@mui/system" "^5.16.13" + "@mui/types" "^7.2.15" + "@mui/utils" "^5.16.13" + "@popperjs/core" "^2.11.8" + "@types/react-transition-group" "^4.4.10" + clsx "^2.1.0" + csstype "^3.1.3" + prop-types "^15.8.1" + react-is "^19.0.0" + react-transition-group "^4.4.5" + +"@mui/private-theming@^5.16.13": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.13.tgz" + integrity sha512-+s0FklvDvO7j0yBZn19DIIT3rLfub2fWvXGtMX49rG/xHfDFcP7fbWbZKHZMMP/2/IoTRDrZCbY1iP0xZlmuJA== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/utils" "^5.16.13" + prop-types "^15.8.1" + +"@mui/styled-engine@^5.16.13": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.13.tgz" + integrity sha512-2XNHEG8/o1ucSLhTA9J+HIIXjzlnEc0OV7kneeUQ5JukErPYT2zc6KYBDLjlKWrzQyvnQzbiffjjspgHUColZg== + dependencies: + "@babel/runtime" "^7.23.9" + "@emotion/cache" "^11.13.5" + csstype "^3.1.3" + prop-types "^15.8.1" + +"@mui/system@^5.16.13": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/system/-/system-5.16.13.tgz" + integrity sha512-JnO3VH3yNoAmgyr44/2jiS1tcNwshwAqAaG5fTEEjHQbkuZT/mvPYj2GC1cON0zEQ5V03xrCNl/D+gU9AXibpw== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/private-theming" "^5.16.13" + "@mui/styled-engine" "^5.16.13" + "@mui/types" "^7.2.15" + "@mui/utils" "^5.16.13" + clsx "^2.1.0" + csstype "^3.1.3" + prop-types "^15.8.1" + +"@mui/types@^7.2.15": + version "7.2.20" + resolved "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz" + integrity sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw== + +"@mui/utils@^5.14.16", "@mui/utils@^5.16.13": + version "5.16.13" + resolved "https://registry.npmjs.org/@mui/utils/-/utils-5.16.13.tgz" + integrity sha512-35kLiShnDPByk57Mz4PP66fQUodCFiOD92HfpW6dK9lc7kjhZsKHRKeYPgWuwEHeXwYsCSFtBCW4RZh/8WT+TQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/types" "^7.2.15" + "@types/prop-types" "^15.7.12" + clsx "^2.1.1" + prop-types "^15.8.1" + react-is "^19.0.0" + +"@mui/x-data-grid@^6.19.2": + version "6.20.4" + resolved "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.4.tgz" + integrity sha512-I0JhinVV4e25hD2dB+R6biPBtpGeFrXf8RwlMPQbr9gUggPmPmNtWKo8Kk2PtBBMlGtdMAgHWe7PqhmucUxU1w== + dependencies: + "@babel/runtime" "^7.23.2" + "@mui/utils" "^5.14.16" + clsx "^2.0.0" + prop-types "^15.8.1" + reselect "^4.1.8" + +"@next/env@14.2.22": + version "14.2.22" + resolved "https://registry.npmjs.org/@next/env/-/env-14.2.22.tgz" + integrity sha512-EQ6y1QeNQglNmNIXvwP/Bb+lf7n9WtgcWvtoFsHquVLCJUuxRs+6SfZ5EK0/EqkkLex4RrDySvKgKNN7PXip7Q== + +"@next/eslint-plugin-next@13.0.4": + version "13.0.4" + resolved "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.0.4.tgz" + integrity sha512-jZ4urKT+aO9QHm3ttihrIQscQISDSKK8isAom750+EySn9o3LCSkTdPGBtvBqY7Yku+NqhfQempR5J58DqTaVg== + dependencies: + glob "7.1.7" + +"@next/swc-darwin-arm64@14.2.22": + version "14.2.22" + resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.22.tgz" + integrity sha512-HUaLiehovgnqY4TMBZJ3pDaOsTE1spIXeR10pWgdQVPYqDGQmHJBj3h3V6yC0uuo/RoY2GC0YBFRkOX3dI9WVQ== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@pkgr/utils@^2.3.1": + version "2.3.1" + resolved "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz" + integrity sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw== + dependencies: + cross-spawn "^7.0.3" + is-glob "^4.0.3" + open "^8.4.0" + picocolors "^1.0.0" + tiny-glob "^0.2.9" + tslib "^2.4.0" + +"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + +"@reduxjs/toolkit@^2.1.0": + version "2.5.0" + resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz" + integrity sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg== + dependencies: + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + +"@restart/hooks@^0.4.7": + version "0.4.16" + resolved "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz" + integrity sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w== + dependencies: + dequal "^2.0.3" + +"@rushstack/eslint-patch@^1.1.3": + version "1.1.4" + resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.4.tgz" + integrity sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA== + +"@seznam/compose-react-refs@^1.0.6": + version "1.0.6" + resolved "https://registry.npmjs.org/@seznam/compose-react-refs/-/compose-react-refs-1.0.6.tgz" + integrity sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q== + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/helpers@0.5.5": + version "0.5.5" + resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz" + integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A== + dependencies: + "@swc/counter" "^0.1.3" + tslib "^2.4.0" + +"@tailwindcss/forms@^0.5.7": + version "0.5.9" + resolved "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz" + integrity sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg== + dependencies: + mini-svg-data-uri "^1.2.3" + +"@tailwindcss/line-clamp@^0.4.4": + version "0.4.4" + resolved "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz" + integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g== + +"@tailwindcss/typography@^0.5.13": + version "0.5.15" + resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz" + integrity sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA== + dependencies: + lodash.castarray "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.merge "^4.6.2" + postcss-selector-parser "6.0.10" + +"@tinymce/tinymce-react@^4.3.2": + version "4.3.2" + resolved "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-4.3.2.tgz" + integrity sha512-wJHZhPf2Mk3yTtdVC/uIGh+kvDgKuTw/qV13uzdChTNo68JI1l7jYMrSQOpyimDyn5LHAw0E1zFByrm1WHAVeA== + dependencies: + prop-types "^15.6.2" + tinymce "^6.0.0 || ^5.5.1" + +"@types/date-arithmetic@*": + version "4.1.4" + resolved "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz" + integrity sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw== + +"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1": + version "3.3.1" + resolved "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + +"@types/json-schema@^7.0.9": + version "7.0.11" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@types/node@18.7.16": + version "18.7.16" + resolved "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz" + integrity sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg== + +"@types/numeral@^2.0.2": + version "2.0.2" + resolved "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.2.tgz" + integrity sha512-A8F30k2gYJ/6e07spSCPpkuZu79LCnkPTvqmIWQzNGcrzwFKpVOydG41lNt5wZXjSI149qjyzC2L1+F2PD/NUA== + +"@types/parse-json@^4.0.0": + version "4.0.2" + resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz" + integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== + +"@types/prop-types@*", "@types/prop-types@^15.7.12": + version "15.7.14" + resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz" + integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + +"@types/react-big-calendar@^1.8.8": + version "1.16.0" + resolved "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-1.16.0.tgz" + integrity sha512-1w2GXAJWlGmaPZOd9J9cyWA/XBNOGRZ4MmRNypEQhwEMIIL9cfd1UdcvzSrQsnBm0qYF/scqmsISNbUzPBE1vg== + dependencies: + "@types/date-arithmetic" "*" + "@types/prop-types" "*" + "@types/react" "*" + +"@types/react-redux@^7.1.24": + version "7.1.24" + resolved "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz" + integrity sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.10": + version "4.4.12" + resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" + integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== + +"@types/react@*", "@types/react@>=16.9.11": + version "18.0.19" + resolved "https://registry.npmjs.org/@types/react/-/react-18.0.19.tgz" + integrity sha512-BDc3Q+4Q3zsn7k9xZrKfjWyJsSlEDMs38gD1qp2eDazLCdcPqAT+vq1ND+Z8AGel/UiwzNUk8ptpywgNQcJ1MQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + +"@types/warning@^3.0.0": + version "3.0.3" + resolved "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz" + integrity sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q== + +"@typescript-eslint/eslint-plugin@^5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.37.0.tgz" + integrity sha512-Fde6W0IafXktz1UlnhGkrrmnnGpAo1kyX7dnyHHVrmwJOn72Oqm3eYtddrpOwwel2W8PAK9F3pIL5S+lfoM0og== + dependencies: + "@typescript-eslint/scope-manager" "5.37.0" + "@typescript-eslint/type-utils" "5.37.0" + "@typescript-eslint/utils" "5.37.0" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/parser@^5.37.0", "@typescript-eslint/parser@^5.42.0": + version "5.43.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.43.0.tgz" + integrity sha512-2iHUK2Lh7PwNUlhFxxLI2haSDNyXvebBO9izhjhMoDC+S3XI9qt2DGFUsiJ89m2k7gGYch2aEpYqV5F/+nwZug== + dependencies: + "@typescript-eslint/scope-manager" "5.43.0" + "@typescript-eslint/types" "5.43.0" + "@typescript-eslint/typescript-estree" "5.43.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.37.0.tgz" + integrity sha512-F67MqrmSXGd/eZnujjtkPgBQzgespu/iCZ+54Ok9X5tALb9L2v3G+QBSoWkXG0p3lcTJsL+iXz5eLUEdSiJU9Q== + dependencies: + "@typescript-eslint/types" "5.37.0" + "@typescript-eslint/visitor-keys" "5.37.0" + +"@typescript-eslint/scope-manager@5.43.0": + version "5.43.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.43.0.tgz" + integrity sha512-XNWnGaqAtTJsUiZaoiGIrdJYHsUOd3BZ3Qj5zKp9w6km6HsrjPk/TGZv0qMTWyWj0+1QOqpHQ2gZOLXaGA9Ekw== + dependencies: + "@typescript-eslint/types" "5.43.0" + "@typescript-eslint/visitor-keys" "5.43.0" + +"@typescript-eslint/type-utils@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.37.0.tgz" + integrity sha512-BSx/O0Z0SXOF5tY0bNTBcDEKz2Ec20GVYvq/H/XNKiUorUFilH7NPbFUuiiyzWaSdN3PA8JV0OvYx0gH/5aFAQ== + dependencies: + "@typescript-eslint/typescript-estree" "5.37.0" + "@typescript-eslint/utils" "5.37.0" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.37.0.tgz" + integrity sha512-3frIJiTa5+tCb2iqR/bf7XwU20lnU05r/sgPJnRpwvfZaqCJBrl8Q/mw9vr3NrNdB/XtVyMA0eppRMMBqdJ1bA== + +"@typescript-eslint/types@5.43.0": + version "5.43.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.43.0.tgz" + integrity sha512-jpsbcD0x6AUvV7tyOlyvon0aUsQpF8W+7TpJntfCUWU1qaIKu2K34pMwQKSzQH8ORgUrGYY6pVIh1Pi8TNeteg== + +"@typescript-eslint/typescript-estree@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.37.0.tgz" + integrity sha512-JkFoFIt/cx59iqEDSgIGnQpCTRv96MQnXCYvJi7QhBC24uyuzbD8wVbajMB1b9x4I0octYFJ3OwjAwNqk1AjDA== + dependencies: + "@typescript-eslint/types" "5.37.0" + "@typescript-eslint/visitor-keys" "5.37.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/typescript-estree@5.43.0": + version "5.43.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.43.0.tgz" + integrity sha512-BZ1WVe+QQ+igWal2tDbNg1j2HWUkAa+CVqdU79L4HP9izQY6CNhXfkNwd1SS4+sSZAP/EthI1uiCSY/+H0pROg== + dependencies: + "@typescript-eslint/types" "5.43.0" + "@typescript-eslint/visitor-keys" "5.43.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.37.0.tgz" + integrity sha512-jUEJoQrWbZhmikbcWSMDuUSxEE7ID2W/QCV/uz10WtQqfOuKZUqFGjqLJ+qhDd17rjgp+QJPqTdPIBWwoob2NQ== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.37.0" + "@typescript-eslint/types" "5.37.0" + "@typescript-eslint/typescript-estree" "5.37.0" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + +"@typescript-eslint/visitor-keys@5.37.0": + version "5.37.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.37.0.tgz" + integrity sha512-Hp7rT4cENBPIzMwrlehLW/28EVCOcE9U1Z1BQTc8EA8v5qpr7GRGuG+U58V5tTY48zvUOA3KHvw3rA8tY9fbdA== + dependencies: + "@typescript-eslint/types" "5.37.0" + eslint-visitor-keys "^3.3.0" + +"@typescript-eslint/visitor-keys@5.43.0": + version "5.43.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.43.0.tgz" + integrity sha512-icl1jNH/d18OVHLfcwdL3bWUKsBeIiKYTGxMJCoGe7xFht+E4QgzOqoWYrU8XSLJWhVw8nTacbm03v23J/hFTg== + dependencies: + "@typescript-eslint/types" "5.43.0" + eslint-visitor-keys "^3.3.0" + +"@vtaits/use-lazy-ref@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@vtaits/use-lazy-ref/-/use-lazy-ref-0.1.3.tgz" + integrity sha512-ZTLuFBHSivPcgWrwkXe5ExVt6R3/ybD+N0yFPy4ClzCztk/9bUD/1udKQ/jd7eCal+lapSrRWXbffqI9jkpDlg== + +"@yr/monotone-cubic-spline@^1.0.3": + version "1.0.3" + resolved "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz" + integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + +ajv@^6.10.0, ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +apexcharts@^3.45.2: + version "3.54.1" + resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-3.54.1.tgz" + integrity sha512-E4et0h/J1U3r3EwS/WlqJCQIbepKbp6wGUmaAwJOMjHUP4Ci0gxanLa7FR3okx6p9coi4st6J853/Cb1NP0vpA== + dependencies: + "@yr/monotone-cubic-spline" "^1.0.3" + svg.draggable.js "^2.2.2" + svg.easing.js "^2.0.0" + svg.filter.js "^2.0.2" + svg.pathmorphing.js "^0.1.3" + svg.resize.js "^1.4.3" + svg.select.js "^3.0.1" + +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +aria-query@^4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz" + integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA== + dependencies: + "@babel/runtime" "^7.10.2" + "@babel/runtime-corejs3" "^7.10.2" + +array-includes@^3.1.4, array-includes@^3.1.5: + version "3.1.5" + resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz" + integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" + get-intrinsic "^1.1.1" + is-string "^1.0.7" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.flat@^1.2.5: + version "1.3.1" + resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz" + integrity sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.20.4" + es-shim-unscopables "^1.0.0" + +array.prototype.flatmap@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz" + integrity sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.2" + es-shim-unscopables "^1.0.0" + +ast-types-flow@^0.0.7: + version "0.0.7" + resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz" + integrity sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +autoprefixer@^10.4.0: + version "10.4.9" + resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.9.tgz" + integrity sha512-Uu67eduPEmOeA0vyJby5ghu1AAELCCNSsLAjK+lz6kYzNM5sqnBO36MqfsjhPjQF/BaJM5U/UuFYyl7PavY/wQ== + dependencies: + browserslist "^4.21.3" + caniuse-lite "^1.0.30001394" + fraction.js "^4.2.0" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + +axe-core@^4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz" + integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w== + +axios@^1.6.7: + version "1.7.9" + resolved "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz" + integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +axobject-query@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" + integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== + +babel-plugin-macros@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz" + integrity sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg== + dependencies: + "@babel/runtime" "^7.12.5" + cosmiconfig "^7.0.0" + resolve "^1.19.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.21.3: + version "4.21.3" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.3.tgz" + integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== + dependencies: + caniuse-lite "^1.0.30001370" + electron-to-chromium "^1.4.202" + node-releases "^2.0.6" + update-browserslist-db "^1.0.5" + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +caniuse-lite@^1.0.30001370, caniuse-lite@^1.0.30001394, caniuse-lite@^1.0.30001579: + version "1.0.30001690" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz" + integrity sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chart.js@^4.4.1: + version "4.4.7" + resolved "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz" + integrity sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw== + dependencies: + "@kurkle/color" "^0.3.0" + +chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chroma-js@^2.4.2: + version "2.6.0" + resolved "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz" + integrity sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A== + +classnames@^2.2.6: + version "2.5.1" + resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + +clsx@^1.1.1: + version "1.2.1" + resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + +clsx@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + +clsx@^2.0.0, clsx@^2.1.0, clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^1.5.0: + version "1.9.0" + resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +core-js-pure@^3.20.2: + version "3.25.1" + resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.25.1.tgz" + integrity sha512-7Fr74bliUDdeJCBMxkkIuQ4xfxn/SwrVg+HkJUAoNEXVqYLv55l6Af0dJ5Lq2YBUW9yKqSkLXaS5SYPK6MGa/A== + +cosmiconfig@^7.0.0: + version "7.1.0" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz" + integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +csstype@^3.0.2, csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +damerau-levenshtein@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" + integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== + +date-arithmetic@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz" + integrity sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg== + +date-fns@^2.30.0: + version "2.30.0" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz" + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== + dependencies: + "@babel/runtime" "^7.21.0" + +dayjs@^1.11.10, dayjs@^1.11.7: + version "1.11.13" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== + +debug@^2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +decode-uri-component@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz" + integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + +define-properties@^1.1.3, define-properties@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz" + integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== + dependencies: + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-helpers@^5.0.1, dom-helpers@^5.2.0, dom-helpers@^5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +electron-to-chromium@^1.4.202: + version "1.4.248" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.248.tgz" + integrity sha512-qShjzEYpa57NnhbW2K+g+Fl+eNoDvQ7I+2MRwWnU6Z6F0HhXekzsECCLv+y2OJUsRodjqoSfwHkIX42VUFtUzg== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +enhanced-resolve@^5.10.0: + version "5.10.0" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz" + integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.4: + version "1.20.4" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz" + integrity sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + function.prototype.name "^1.1.5" + get-intrinsic "^1.1.3" + get-symbol-description "^1.0.0" + has "^1.0.3" + has-property-descriptors "^1.0.0" + has-symbols "^1.0.3" + internal-slot "^1.0.3" + is-callable "^1.2.7" + is-negative-zero "^2.0.2" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + is-string "^1.0.7" + is-weakref "^1.0.2" + object-inspect "^1.12.2" + object-keys "^1.1.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.4.3" + safe-regex-test "^1.0.0" + string.prototype.trimend "^1.0.5" + string.prototype.trimstart "^1.0.5" + unbox-primitive "^1.0.2" + +es-shim-unscopables@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz" + integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== + dependencies: + has "^1.0.3" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-next@^13.0.4: + version "13.0.4" + resolved "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.0.4.tgz" + integrity sha512-moEC7BW2TK7JKq3QfnaauqRjWzVcEf71gp5DbClpFPHM6QXE0u0uVvSTiHlmOgtCe1vyWAO+AhF87ZITd8mIDw== + dependencies: + "@next/eslint-plugin-next" "13.0.4" + "@rushstack/eslint-patch" "^1.1.3" + "@typescript-eslint/parser" "^5.42.0" + eslint-import-resolver-node "^0.3.6" + eslint-import-resolver-typescript "^3.5.2" + eslint-plugin-import "^2.26.0" + eslint-plugin-jsx-a11y "^6.5.1" + eslint-plugin-react "^7.31.7" + eslint-plugin-react-hooks "^4.5.0" + +eslint-config-prettier@^8.5.0: + version "8.5.0" + resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz" + integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== + +eslint-import-resolver-node@^0.3.6: + version "0.3.6" + resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz" + integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw== + dependencies: + debug "^3.2.7" + resolve "^1.20.0" + +eslint-import-resolver-typescript@^3.5.2: + version "3.5.2" + resolved "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.2.tgz" + integrity sha512-zX4ebnnyXiykjhcBvKIf5TNvt8K7yX6bllTRZ14MiurKPjDpCAZujlszTdB8pcNXhZcOf+god4s9SjQa5GnytQ== + dependencies: + debug "^4.3.4" + enhanced-resolve "^5.10.0" + get-tsconfig "^4.2.0" + globby "^13.1.2" + is-core-module "^2.10.0" + is-glob "^4.0.3" + synckit "^0.8.4" + +eslint-module-utils@^2.7.3: + version "2.7.4" + resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz" + integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== + dependencies: + debug "^3.2.7" + +eslint-plugin-import@^2.26.0: + version "2.26.0" + resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz" + integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== + dependencies: + array-includes "^3.1.4" + array.prototype.flat "^1.2.5" + debug "^2.6.9" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.6" + eslint-module-utils "^2.7.3" + has "^1.0.3" + is-core-module "^2.8.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.values "^1.1.5" + resolve "^1.22.0" + tsconfig-paths "^3.14.1" + +eslint-plugin-jsx-a11y@^6.5.1: + version "6.6.1" + resolved "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz" + integrity sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q== + dependencies: + "@babel/runtime" "^7.18.9" + aria-query "^4.2.2" + array-includes "^3.1.5" + ast-types-flow "^0.0.7" + axe-core "^4.4.3" + axobject-query "^2.2.0" + damerau-levenshtein "^1.0.8" + emoji-regex "^9.2.2" + has "^1.0.3" + jsx-ast-utils "^3.3.2" + language-tags "^1.0.5" + minimatch "^3.1.2" + semver "^6.3.0" + +eslint-plugin-react-hooks@^4.5.0: + version "4.6.0" + resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz" + integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== + +eslint-plugin-react@^7.31.7: + version "7.31.8" + resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.8.tgz" + integrity sha512-5lBTZmgQmARLLSYiwI71tiGVTLUuqXantZM6vlSY39OaDSV0M7+32K5DnLkmFrwTe+Ksz0ffuLUC91RUviVZfw== + dependencies: + array-includes "^3.1.5" + array.prototype.flatmap "^1.3.0" + doctrine "^2.1.0" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.5" + object.fromentries "^2.0.5" + object.hasown "^1.1.1" + object.values "^1.1.5" + prop-types "^15.8.1" + resolve "^2.0.0-next.3" + semver "^6.3.0" + string.prototype.matchall "^4.0.7" + +eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz" + integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== + dependencies: + eslint-visitor-keys "^2.0.0" + +eslint-visitor-keys@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz" + integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== + +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + +eslint@^8.23.1: + version "8.23.1" + resolved "https://registry.npmjs.org/eslint/-/eslint-8.23.1.tgz" + integrity sha512-w7C1IXCc6fNqjpuYd0yPlcTKKmHlHHktRkzmBPZ+7cvNBQuiNjx0xaMTjAJGCafJhQkrFJooREv0CtrVzmHwqg== + dependencies: + "@eslint/eslintrc" "^1.3.2" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-sdsl "^4.1.4" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + +esquery@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz" + integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.11, fast-glob@^3.2.9, fast-glob@^3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.13.0" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz" + integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== + dependencies: + reusify "^1.0.4" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +filter-obj@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz" + integrity sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng== + +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz" + integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + dependencies: + flatted "^3.1.0" + rimraf "^3.0.2" + +flatted@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== + +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +foreground-child@^3.1.0: + version "3.3.0" + resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz" + integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formik@^2.4.5: + version "2.4.6" + resolved "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz" + integrity sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g== + dependencies: + "@types/hoist-non-react-statics" "^3.3.1" + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^2.0.0" + +fraction.js@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz" + integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz" + integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + functions-have-names "^1.2.2" + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz" + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== + +functions-have-names@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz" + integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.3" + +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + +get-tsconfig@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.2.0.tgz" + integrity sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.1, glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.3.10: + version "10.4.5" + resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3, glob@7.1.7: + version "7.1.7" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globalize@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/globalize/-/globalize-0.1.1.tgz" + integrity sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA== + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.15.0: + version "13.17.0" + resolved "https://registry.npmjs.org/globals/-/globals-13.17.0.tgz" + integrity sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw== + dependencies: + type-fest "^0.20.2" + +globalyzer@0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz" + integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +globby@^13.1.2: + version "13.1.2" + resolved "https://registry.npmjs.org/globby/-/globby-13.1.2.tgz" + integrity sha512-LKSDZXToac40u8Q1PQtZihbNdTYSNMuWe+K5l+oa6KgDzSvVrHXlJy40hUP522RjAIoNLJYBJi7ow+rbFpIhHQ== + dependencies: + dir-glob "^3.0.1" + fast-glob "^3.2.11" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^4.0.0" + +globrex@^0.1.2: + version "0.1.2" + resolved "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz" + integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== + +graceful-fs@^4.2.11, graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +ignore@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz" + integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== + +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + +import-fresh@^3.0.0, import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +internal-slot@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz" + integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== + dependencies: + get-intrinsic "^1.1.0" + has "^1.0.3" + side-channel "^1.0.4" + +intro.js-react@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/intro.js-react/-/intro.js-react-1.0.0.tgz" + integrity sha512-zR8pbTyX20RnCZpJMc0nuHBpsjcr1wFkj3ZookV6Ly4eE/LGpFTQwPsaA61Cryzwiy/tTFsusf4hPU9NpI9UOg== + +intro.js@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/intro.js/-/intro.js-7.2.0.tgz" + integrity sha512-qbMfaB70rOXVBceIWNYnYTpVTiZsvQh/MIkfdQbpA9di9VBfj1GigUPfcCv3aOfsbrtPcri8vTLTA4FcEDcHSQ== + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.10.0, is-core-module@^2.16.0, is-core-module@^2.8.1, is-core-module@^2.9.0: + version "2.16.1" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-negative-zero@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz" + integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jiti@^1.21.6: + version "1.21.7" + resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz" + integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== + +js-sdsl@^4.1.4: + version "4.1.4" + resolved "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.4.tgz" + integrity sha512-Y2/yD55y5jteOAmY50JbUZYwk3CP3wnLPEZnlR1w9oKhITrBEtAxwuWKebFf8hMrPMgbYwFoWK/lH2sBkErELw== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +jsonwebtoken@^9.0.2: + version "9.0.2" + resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.2: + version "3.3.3" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz" + integrity sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw== + dependencies: + array-includes "^3.1.5" + object.assign "^4.1.3" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + +krustykrab@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/krustykrab/-/krustykrab-1.1.0.tgz" + integrity sha512-xpX9MPbw+nJseewe6who9Oq46RQwrBfps+dO/N4fSjJhsf2+y4XWC2kz46oBGX8yzMHyYJj35ug0X5s5yxB6tA== + +language-subtag-registry@~0.3.2: + version "0.3.22" + resolved "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz" + integrity sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w== + +language-tags@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz" + integrity sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ== + dependencies: + language-subtag-registry "~0.3.2" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lilconfig@^3.0.0, lilconfig@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.castarray@^4.4.0: + version "4.4.0" + resolved "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz" + integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +loose-envify@^1.0.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +luxon@^3.2.1: + version "3.5.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mini-svg-data-uri@^1.2.3: + version "1.4.4" + resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz" + integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg== + +minimatch@^3.0.4, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +moment-timezone@^0.5.40: + version "0.5.46" + resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.46.tgz" + integrity sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw== + dependencies: + moment "^2.29.4" + +moment@^2.29.4: + version "2.30.1" + resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +ms@^2.1.1, ms@2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.6, nanoid@^3.3.7: + version "3.3.8" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +next@^14.1.0: + version "14.2.22" + resolved "https://registry.npmjs.org/next/-/next-14.2.22.tgz" + integrity sha512-Ps2caobQ9hlEhscLPiPm3J3SYhfwfpMqzsoCMZGWxt9jBRK9hoBZj2A37i8joKhsyth2EuVKDVJCTF5/H4iEDw== + dependencies: + "@next/env" "14.2.22" + "@swc/helpers" "0.5.5" + busboy "1.6.0" + caniuse-lite "^1.0.30001579" + graceful-fs "^4.2.11" + postcss "8.4.31" + styled-jsx "5.1.1" + optionalDependencies: + "@next/swc-darwin-arm64" "14.2.22" + "@next/swc-darwin-x64" "14.2.22" + "@next/swc-linux-arm64-gnu" "14.2.22" + "@next/swc-linux-arm64-musl" "14.2.22" + "@next/swc-linux-x64-gnu" "14.2.22" + "@next/swc-linux-x64-musl" "14.2.22" + "@next/swc-win32-arm64-msvc" "14.2.22" + "@next/swc-win32-ia32-msvc" "14.2.22" + "@next/swc-win32-x64-msvc" "14.2.22" + +node-releases@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz" + integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + +numeral@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz" + integrity sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA== + +object-assign@^4.0.1, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +object-inspect@^1.12.2, object-inspect@^1.9.0: + version "1.12.2" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.3, object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +object.entries@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz" + integrity sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + +object.fromentries@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz" + integrity sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + +object.hasown@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.1.tgz" + integrity sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A== + dependencies: + define-properties "^1.1.4" + es-abstract "^1.19.5" + +object.values@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz" + integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +open@^8.4.0: + version "8.4.0" + resolved "https://registry.npmjs.org/open/-/open-8.4.0.tgz" + integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + +optionator@^0.9.1: + version "0.9.1" + resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz" + integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.3" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picocolors@^1.0.0, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pirates@^4.0.1: + version "4.0.6" + resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz" + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + +postcss-import@^14.1.0: + version "14.1.0" + resolved "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz" + integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-import@^15.1.0: + version "15.1.0" + resolved "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz" + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz" + integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== + dependencies: + camelcase-css "^2.0.1" + +postcss-load-config@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz" + integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ== + dependencies: + lilconfig "^3.0.0" + yaml "^2.3.4" + +postcss-nested@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz" + integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ== + dependencies: + postcss-selector-parser "^6.1.1" + +postcss-selector-parser@^6.1.1: + version "6.1.2" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-selector-parser@^6.1.2: + version "6.1.2" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-selector-parser@6.0.10: + version "6.0.10" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.4, postcss@^8.4.47: + version "8.4.49" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^3.2.4: + version "3.4.2" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz" + integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== + +prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +query-string@^8.1.0: + version "8.2.0" + resolved "https://registry.npmjs.org/query-string/-/query-string-8.2.0.tgz" + integrity sha512-tUZIw8J0CawM5wyGBiDOAp7ObdRQh4uBor/fUR9ZjmbZVvw95OD9If4w3MQxr99rg0DJZ/9CIORcpEqU5hQG7g== + dependencies: + decode-uri-component "^0.4.1" + filter-obj "^5.1.0" + split-on-first "^3.0.0" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-apexcharts@^1.4.1: + version "1.7.0" + resolved "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.7.0.tgz" + integrity sha512-03oScKJyNLRf0Oe+ihJxFZliBQM9vW3UWwomVn4YVRTN1jsIR58dLWt0v1sb8RwJVHDMbeHiKQueM0KGpn7nOA== + dependencies: + prop-types "^15.8.1" + +react-big-calendar@^1.10.3: + version "1.17.1" + resolved "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.17.1.tgz" + integrity sha512-LltUAMSGODWQBKx4013bRe6R0jaINV9hrs970+F860KedpozwRGGMT66esV9mA3mAhfSKoazF/QH1WCyLkXYZA== + dependencies: + "@babel/runtime" "^7.20.7" + clsx "^1.2.1" + date-arithmetic "^4.1.0" + dayjs "^1.11.7" + dom-helpers "^5.2.1" + globalize "^0.1.1" + invariant "^2.2.4" + lodash "^4.17.21" + lodash-es "^4.17.21" + luxon "^3.2.1" + memoize-one "^6.0.0" + moment "^2.29.4" + moment-timezone "^0.5.40" + prop-types "^15.8.1" + react-overlays "^5.2.1" + uncontrollable "^7.2.1" + +react-chartjs-2@^4.3.1: + version "4.3.1" + resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz" + integrity sha512-5i3mjP6tU7QSn0jvb8I4hudTzHJqS8l00ORJnVwI2sYu0ihpj83Lv2YzfxunfxTZkscKvZu2F2w9LkwNBhj6xA== + +react-datepicker@^4.10.0: + version "4.25.0" + resolved "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.25.0.tgz" + integrity sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg== + dependencies: + "@popperjs/core" "^2.11.8" + classnames "^2.2.6" + date-fns "^2.30.0" + prop-types "^15.7.2" + react-onclickoutside "^6.13.0" + react-popper "^2.3.0" + +react-dom@^19.0.0: + version "19.0.0" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== + dependencies: + scheduler "^0.25.0" + +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + +react-fast-compare@^3.0.1: + version "3.2.2" + resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz" + integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== + +react-is@^16.13.1, react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + +react-is@^19.0.0: + version "19.0.0" + resolved "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz" + integrity sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g== + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-onclickoutside@^6.13.0: + version "6.13.1" + resolved "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.1.tgz" + integrity sha512-LdrrxK/Yh9zbBQdFbMTXPp3dTSN9B+9YJQucdDu3JNKRrbdU+H+/TVONJoWtOwy4II8Sqf1y/DTI6w/vGPYW0w== + +react-overlays@^5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz" + integrity sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA== + dependencies: + "@babel/runtime" "^7.13.8" + "@popperjs/core" "^2.11.6" + "@restart/hooks" "^0.4.7" + "@types/warning" "^3.0.0" + dom-helpers "^5.2.0" + prop-types "^15.7.2" + uncontrollable "^7.2.1" + warning "^4.0.3" + +react-popper@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz" + integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + +react-redux@^8.0.2: + version "8.0.2" + resolved "https://registry.npmjs.org/react-redux/-/react-redux-8.0.2.tgz" + integrity sha512-nBwiscMw3NoP59NFCXFf02f8xdo+vSHT/uZ1ldDwF7XaTpzm+Phk97VT4urYBl5TYAPNVaFm12UHAEyzkpNzRA== + dependencies: + "@babel/runtime" "^7.12.1" + "@types/hoist-non-react-statics" "^3.3.1" + "@types/use-sync-external-store" "^0.0.3" + hoist-non-react-statics "^3.3.2" + react-is "^18.0.0" + use-sync-external-store "^1.0.0" + +react-select-async-paginate@^0.7.9: + version "0.7.9" + resolved "https://registry.npmjs.org/react-select-async-paginate/-/react-select-async-paginate-0.7.9.tgz" + integrity sha512-oGdmXWatj06MNUcLwFkyt8qLBA0km0gFvDF52qaQQEArRjprbTajaYwhtjVotKTw1IXfw3fiq3G9Lgsjfpjz/Q== + dependencies: + "@seznam/compose-react-refs" "^1.0.6" + "@vtaits/use-lazy-ref" "^0.1.3" + krustykrab "^1.1.0" + sleep-promise "^9.1.0" + use-is-mounted-ref "^1.5.0" + use-latest "^1.3.0" + +react-select@^5.7.0: + version "5.9.0" + resolved "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz" + integrity sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw== + dependencies: + "@babel/runtime" "^7.12.0" + "@emotion/cache" "^11.4.0" + "@emotion/react" "^11.8.1" + "@floating-ui/dom" "^1.0.1" + "@types/react-transition-group" "^4.4.0" + memoize-one "^6.0.0" + prop-types "^15.6.0" + react-transition-group "^4.3.0" + use-isomorphic-layout-effect "^1.2.0" + +react-switch@^7.0.0: + version "7.1.0" + resolved "https://registry.npmjs.org/react-switch/-/react-switch-7.1.0.tgz" + integrity sha512-4xVeyImZE8QOTDw2FmhWz0iqo2psoRiS7XzdjaZBCIP8Dzo3rT0esHUjLee5WsAPSFXWWl1eVA5arp9n2C6yQA== + dependencies: + prop-types "^15.7.2" + +react-toastify@^9.1.2: + version "9.1.3" + resolved "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz" + integrity sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg== + dependencies: + clsx "^1.1.1" + +react-transition-group@^4.3.0, react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + +react@^19.0.0: + version "19.0.0" + resolved "https://registry.npmjs.org/react/-/react-19.0.0.tgz" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz" + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== + dependencies: + "@babel/runtime" "^7.9.2" + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + +regenerator-runtime@^0.13.4: + version "0.13.9" + resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz" + integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: + version "1.4.3" + resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz" + integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + functions-have-names "^1.2.2" + +regexpp@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz" + integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== + +reselect@^4.1.8: + version "4.1.8" + resolved "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz" + integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== + +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve@^1.1.7, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.8: + version "1.22.10" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^2.0.0-next.3: + version "2.0.0-next.4" + resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz" + integrity sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@^5.0.1: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-regex-test@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz" + integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + is-regex "^1.1.4" + +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== + +semver@^6.3.0: + version "6.3.0" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@^7.3.7, semver@^7.5.4: + version "7.6.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slash@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz" + integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== + +sleep-promise@^9.1.0: + version "9.1.0" + resolved "https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz" + integrity sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA== + +source-map-js@^1.0.2, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + +split-on-first@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz" + integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.matchall@^4.0.7: + version "4.0.7" + resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz" + integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + get-intrinsic "^1.1.1" + has-symbols "^1.0.3" + internal-slot "^1.0.3" + regexp.prototype.flags "^1.4.1" + side-channel "^1.0.4" + +string.prototype.trimend@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz" + integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" + +string.prototype.trimstart@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz" + integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +styled-jsx@5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz" + integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw== + dependencies: + client-only "0.0.1" + +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + +sucrase@^3.35.0: + version "3.35.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz" + integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "^10.3.10" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +svg.draggable.js@^2.2.2: + version "2.2.2" + resolved "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz" + integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw== + dependencies: + svg.js "^2.0.1" + +svg.easing.js@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz" + integrity sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA== + dependencies: + svg.js ">=2.3.x" + +svg.filter.js@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz" + integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw== + dependencies: + svg.js "^2.2.5" + +svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5, svg.js@>=2.3.x: + version "2.7.1" + resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz" + integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== + +svg.pathmorphing.js@^0.1.3: + version "0.1.3" + resolved "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz" + integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww== + dependencies: + svg.js "^2.4.0" + +svg.resize.js@^1.4.3: + version "1.4.3" + resolved "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz" + integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw== + dependencies: + svg.js "^2.6.5" + svg.select.js "^2.1.2" + +svg.select.js@^2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz" + integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ== + dependencies: + svg.js "^2.2.5" + +svg.select.js@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz" + integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw== + dependencies: + svg.js "^2.6.5" + +swr@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz" + integrity sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw== + +synckit@^0.8.4: + version "0.8.4" + resolved "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz" + integrity sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw== + dependencies: + "@pkgr/utils" "^2.3.1" + tslib "^2.4.0" + +tailwindcss@^3.4.1: + version "3.4.17" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz" + integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.6.0" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.3.2" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.21.6" + lilconfig "^3.1.3" + micromatch "^4.0.8" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.1.1" + postcss "^8.4.47" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.2" + postcss-nested "^6.2.0" + postcss-selector-parser "^6.1.2" + resolve "^1.22.8" + sucrase "^3.35.0" + +tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +tiny-glob@^0.2.9: + version "0.2.9" + resolved "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz" + integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== + dependencies: + globalyzer "0.1.0" + globrex "^0.1.2" + +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + +"tinymce@^6.0.0 || ^5.5.1": + version "6.8.5" + resolved "https://registry.npmjs.org/tinymce/-/tinymce-6.8.5.tgz" + integrity sha512-qAL/FxL7cwZHj4BfaF818zeJJizK9jU5IQzTcSLL4Rj5MaJdiVblEj7aDr80VCV1w9h4Lak9hlnALhq/kVtN1g== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +tsconfig-paths@^3.14.1: + version "3.14.1" + resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz" + integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^1.8.1: + version "1.14.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.0.0, tslib@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +typescript@^4.8.3: + version "4.8.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz" + integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +uncontrollable@^7.2.1: + version "7.2.1" + resolved "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz" + integrity sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ== + dependencies: + "@babel/runtime" "^7.6.3" + "@types/react" ">=16.9.11" + invariant "^2.2.4" + react-lifecycles-compat "^3.0.4" + +update-browserslist-db@^1.0.5: + version "1.0.9" + resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz" + integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +use-is-mounted-ref@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/use-is-mounted-ref/-/use-is-mounted-ref-1.5.0.tgz" + integrity sha512-p5FksHf/ospZUr5KU9ese6u3jp9fzvZ3wuSb50i0y6fdONaHWgmOqQtxR/PUcwi6hnhQDbNxWSg3eTK3N6m+dg== + +use-isomorphic-layout-effect@^1.1.1, use-isomorphic-layout-effect@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz" + integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== + +use-latest@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + +use-sync-external-store@^1.0.0: + version "1.2.0" + resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + +warning@^4.0.2, warning@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.3: + version "1.2.3" + resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yaml@^1.10.0: + version "1.10.2" + resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yaml@^2.3.4: + version "2.7.0" + resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz" + integrity sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==