mirror of
https://github.com/cheveguerra/whaticket-community.git
synced 2026-04-18 03:39:29 +00:00
Merge pull request #108 from canove/queue
Add queue segregation to tickets, whatsapps and users. Added a simple auto reply to whatsapps and queues.
This commit is contained in:
@@ -32,11 +32,7 @@ app.use(Sentry.Handlers.errorHandler());
|
||||
|
||||
app.use(async (err: Error, req: Request, res: Response, _: NextFunction) => {
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode === 403) {
|
||||
logger.warn(err);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
logger.warn(err);
|
||||
return res.status(err.statusCode).json({ error: err.message });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
secret: process.env.JWT_SECRET || "mysecret",
|
||||
expiresIn: "15m",
|
||||
expiresIn: "15d",
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret",
|
||||
refreshExpiresIn: "7d"
|
||||
};
|
||||
|
||||
69
backend/src/controllers/QueueController.ts
Normal file
69
backend/src/controllers/QueueController.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Request, Response } from "express";
|
||||
import { getIO } from "../libs/socket";
|
||||
import CreateQueueService from "../services/QueueService/CreateQueueService";
|
||||
import DeleteQueueService from "../services/QueueService/DeleteQueueService";
|
||||
import ListQueuesService from "../services/QueueService/ListQueuesService";
|
||||
import ShowQueueService from "../services/QueueService/ShowQueueService";
|
||||
import UpdateQueueService from "../services/QueueService/UpdateQueueService";
|
||||
|
||||
export const index = async (req: Request, res: Response): Promise<Response> => {
|
||||
const queues = await ListQueuesService();
|
||||
|
||||
return res.status(200).json(queues);
|
||||
};
|
||||
|
||||
export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { name, color, greetingMessage } = req.body;
|
||||
|
||||
const queue = await CreateQueueService({ name, color, greetingMessage });
|
||||
|
||||
const io = getIO();
|
||||
io.emit("queue", {
|
||||
action: "update",
|
||||
queue
|
||||
});
|
||||
|
||||
return res.status(200).json(queue);
|
||||
};
|
||||
|
||||
export const show = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { queueId } = req.params;
|
||||
|
||||
const queue = await ShowQueueService(queueId);
|
||||
|
||||
return res.status(200).json(queue);
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const { queueId } = req.params;
|
||||
|
||||
const queue = await UpdateQueueService(queueId, req.body);
|
||||
|
||||
const io = getIO();
|
||||
io.emit("queue", {
|
||||
action: "update",
|
||||
queue
|
||||
});
|
||||
|
||||
return res.status(201).json(queue);
|
||||
};
|
||||
|
||||
export const remove = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const { queueId } = req.params;
|
||||
|
||||
await DeleteQueueService(queueId);
|
||||
|
||||
const io = getIO();
|
||||
io.emit("queue", {
|
||||
action: "delete",
|
||||
queueId: +queueId
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { RefreshTokenService } from "../services/AuthServices/RefreshTokenServic
|
||||
export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const { token, user, refreshToken } = await AuthUserService({
|
||||
const { token, serializedUser, refreshToken } = await AuthUserService({
|
||||
email,
|
||||
password
|
||||
});
|
||||
@@ -17,9 +17,7 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
username: user.name,
|
||||
profile: user.profile,
|
||||
userId: user.id
|
||||
user: serializedUser
|
||||
});
|
||||
};
|
||||
|
||||
@@ -33,9 +31,9 @@ export const update = async (
|
||||
throw new AppError("ERR_SESSION_EXPIRED", 401);
|
||||
}
|
||||
|
||||
const { newToken, refreshToken } = await RefreshTokenService(token);
|
||||
const { user, newToken, refreshToken } = await RefreshTokenService(token);
|
||||
|
||||
SendRefreshToken(res, refreshToken);
|
||||
|
||||
return res.json({ token: newToken });
|
||||
return res.json({ token: newToken, user });
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ type IndexQuery = {
|
||||
date: string;
|
||||
showAll: string;
|
||||
withUnreadMessages: string;
|
||||
queueIds: string;
|
||||
};
|
||||
|
||||
interface TicketData {
|
||||
@@ -29,11 +30,18 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
||||
date,
|
||||
searchParam,
|
||||
showAll,
|
||||
queueIds: queueIdsStringified,
|
||||
withUnreadMessages
|
||||
} = req.query as IndexQuery;
|
||||
|
||||
const userId = req.user.id;
|
||||
|
||||
let queueIds: number[] = [];
|
||||
|
||||
if (queueIdsStringified) {
|
||||
queueIds = JSON.parse(queueIdsStringified);
|
||||
}
|
||||
|
||||
const { tickets, count, hasMore } = await ListTicketsService({
|
||||
searchParam,
|
||||
pageNumber,
|
||||
@@ -41,6 +49,7 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
||||
date,
|
||||
showAll,
|
||||
userId,
|
||||
queueIds,
|
||||
withUnreadMessages
|
||||
});
|
||||
|
||||
@@ -54,7 +63,7 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
|
||||
const io = getIO();
|
||||
io.to(ticket.status).emit("ticket", {
|
||||
action: "create",
|
||||
action: "update",
|
||||
ticket
|
||||
});
|
||||
|
||||
@@ -91,7 +100,7 @@ export const update = async (
|
||||
}
|
||||
|
||||
io.to(ticket.status).to("notification").to(ticketId).emit("ticket", {
|
||||
action: "updateStatus",
|
||||
action: "update",
|
||||
ticket
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
||||
};
|
||||
|
||||
export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password, name, profile } = req.body;
|
||||
const { email, password, name, profile, queueIds } = req.body;
|
||||
|
||||
if (
|
||||
req.url === "/signup" &&
|
||||
@@ -42,7 +42,8 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
profile
|
||||
profile,
|
||||
queueIds
|
||||
});
|
||||
|
||||
const io = getIO();
|
||||
|
||||
@@ -11,6 +11,8 @@ import UpdateWhatsAppService from "../services/WhatsappService/UpdateWhatsAppSer
|
||||
|
||||
interface WhatsappData {
|
||||
name: string;
|
||||
queueIds: number[];
|
||||
greetingMessage?: string;
|
||||
status?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -22,15 +24,23 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
||||
};
|
||||
|
||||
export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { name, status, isDefault }: WhatsappData = req.body;
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
isDefault,
|
||||
greetingMessage,
|
||||
queueIds
|
||||
}: WhatsappData = req.body;
|
||||
|
||||
const { whatsapp, oldDefaultWhatsapp } = await CreateWhatsAppService({
|
||||
name,
|
||||
status,
|
||||
isDefault
|
||||
isDefault,
|
||||
greetingMessage,
|
||||
queueIds
|
||||
});
|
||||
|
||||
StartWhatsAppSession(whatsapp);
|
||||
// StartWhatsAppSession(whatsapp);
|
||||
|
||||
const io = getIO();
|
||||
io.emit("whatsapp", {
|
||||
|
||||
@@ -6,6 +6,9 @@ import Ticket from "../models/Ticket";
|
||||
import Whatsapp from "../models/Whatsapp";
|
||||
import ContactCustomField from "../models/ContactCustomField";
|
||||
import Message from "../models/Message";
|
||||
import Queue from "../models/Queue";
|
||||
import WhatsappQueue from "../models/WhatsappQueue";
|
||||
import UserQueue from "../models/UserQueue";
|
||||
|
||||
// eslint-disable-next-line
|
||||
const dbConfig = require("../config/database");
|
||||
@@ -20,7 +23,10 @@ const models = [
|
||||
Message,
|
||||
Whatsapp,
|
||||
ContactCustomField,
|
||||
Setting
|
||||
Setting,
|
||||
Queue,
|
||||
WhatsappQueue,
|
||||
UserQueue
|
||||
];
|
||||
|
||||
sequelize.addModels(models);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { QueryInterface, DataTypes } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.createTable("Queues", {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
color: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
greetingMessage: {
|
||||
type: DataTypes.TEXT
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.dropTable("Queues");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryInterface, DataTypes } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.addColumn("Tickets", "queueId", {
|
||||
type: DataTypes.INTEGER,
|
||||
references: { model: "Queues", key: "id" },
|
||||
onUpdate: "CASCADE",
|
||||
onDelete: "SET NULL"
|
||||
});
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.removeColumn("Tickets", "queueId");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { QueryInterface, DataTypes } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.createTable("WhatsappQueues", {
|
||||
whatsappId: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true
|
||||
},
|
||||
queueId: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.dropTable("WhatsappQueues");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { QueryInterface, DataTypes } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.createTable("UserQueues", {
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true
|
||||
},
|
||||
queueId: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.dropTable("UserQueues");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { QueryInterface, DataTypes } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.addColumn("Whatsapps", "greetingMessage", {
|
||||
type: DataTypes.TEXT
|
||||
});
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.removeColumn("Whatsapps", "greetingMessage");
|
||||
}
|
||||
};
|
||||
41
backend/src/helpers/Debounce.ts
Normal file
41
backend/src/helpers/Debounce.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
interface Timeout {
|
||||
id: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const timeouts: Timeout[] = [];
|
||||
|
||||
const findAndClearTimeout = (ticketId: number) => {
|
||||
if (timeouts.length > 0) {
|
||||
const timeoutIndex = timeouts.findIndex(timeout => timeout.id === ticketId);
|
||||
|
||||
if (timeoutIndex !== -1) {
|
||||
clearTimeout(timeouts[timeoutIndex].timeout);
|
||||
timeouts.splice(timeoutIndex, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = (
|
||||
func: { (): Promise<void>; (...args: never[]): void },
|
||||
wait: number,
|
||||
ticketId: number
|
||||
) => {
|
||||
return function executedFunction(...args: never[]): void {
|
||||
const later = () => {
|
||||
findAndClearTimeout(ticketId);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
findAndClearTimeout(ticketId);
|
||||
|
||||
const newTimeout = {
|
||||
id: ticketId,
|
||||
timeout: setTimeout(later, wait)
|
||||
};
|
||||
|
||||
timeouts.push(newTimeout);
|
||||
};
|
||||
};
|
||||
|
||||
export { debounce };
|
||||
20
backend/src/helpers/SerializeUser.ts
Normal file
20
backend/src/helpers/SerializeUser.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Queue from "../models/Queue";
|
||||
import User from "../models/User";
|
||||
|
||||
interface SerializedUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
profile: string;
|
||||
queues: Queue[];
|
||||
}
|
||||
|
||||
export const SerializeUser = (user: User): SerializedUser => {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
queues: user.queues
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,7 @@ const isAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
throw new AppError("Token was not provided.", 403);
|
||||
throw new AppError("ERR_SESSION_EXPIRED", 401);
|
||||
}
|
||||
|
||||
const [, token] = authHeader.split(" ");
|
||||
|
||||
53
backend/src/models/Queue.ts
Normal file
53
backend/src/models/Queue.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
Model,
|
||||
PrimaryKey,
|
||||
AutoIncrement,
|
||||
AllowNull,
|
||||
Unique,
|
||||
BelongsToMany,
|
||||
HasMany
|
||||
} from "sequelize-typescript";
|
||||
import User from "./User";
|
||||
import UserQueue from "./UserQueue";
|
||||
|
||||
import Whatsapp from "./Whatsapp";
|
||||
import WhatsappQueue from "./WhatsappQueue";
|
||||
|
||||
@Table
|
||||
class Queue extends Model<Queue> {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column
|
||||
id: number;
|
||||
|
||||
@AllowNull(false)
|
||||
@Unique
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@AllowNull(false)
|
||||
@Unique
|
||||
@Column
|
||||
color: string;
|
||||
|
||||
@Column
|
||||
greetingMessage: string;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date;
|
||||
|
||||
@BelongsToMany(() => Whatsapp, () => WhatsappQueue)
|
||||
whatsapps: Array<Whatsapp & { WhatsappQueue: WhatsappQueue }>;
|
||||
|
||||
@BelongsToMany(() => User, () => UserQueue)
|
||||
users: Array<User & { UserQueue: UserQueue }>;
|
||||
}
|
||||
|
||||
export default Queue;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
import Contact from "./Contact";
|
||||
import Message from "./Message";
|
||||
import Queue from "./Queue";
|
||||
import User from "./User";
|
||||
import Whatsapp from "./Whatsapp";
|
||||
|
||||
@@ -64,6 +65,13 @@ class Ticket extends Model<Ticket> {
|
||||
@BelongsTo(() => Whatsapp)
|
||||
whatsapp: Whatsapp;
|
||||
|
||||
@ForeignKey(() => Queue)
|
||||
@Column
|
||||
queueId: number;
|
||||
|
||||
@BelongsTo(() => Queue)
|
||||
queue: Queue;
|
||||
|
||||
@HasMany(() => Message)
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
PrimaryKey,
|
||||
AutoIncrement,
|
||||
Default,
|
||||
HasMany
|
||||
HasMany,
|
||||
BelongsToMany
|
||||
} from "sequelize-typescript";
|
||||
import { hash, compare } from "bcryptjs";
|
||||
import Ticket from "./Ticket";
|
||||
import Queue from "./Queue";
|
||||
import UserQueue from "./UserQueue";
|
||||
|
||||
@Table
|
||||
class User extends Model<User> {
|
||||
@@ -51,6 +54,9 @@ class User extends Model<User> {
|
||||
@HasMany(() => Ticket)
|
||||
tickets: Ticket[];
|
||||
|
||||
@BelongsToMany(() => Queue, () => UserQueue)
|
||||
queues: Queue[];
|
||||
|
||||
@BeforeUpdate
|
||||
@BeforeCreate
|
||||
static hashPassword = async (instance: User): Promise<void> => {
|
||||
|
||||
29
backend/src/models/UserQueue.ts
Normal file
29
backend/src/models/UserQueue.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
Model,
|
||||
ForeignKey
|
||||
} from "sequelize-typescript";
|
||||
import Queue from "./Queue";
|
||||
import User from "./User";
|
||||
|
||||
@Table
|
||||
class UserQueue extends Model<UserQueue> {
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId: number;
|
||||
|
||||
@ForeignKey(() => Queue)
|
||||
@Column
|
||||
queueId: number;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default UserQueue;
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Default,
|
||||
AllowNull,
|
||||
HasMany,
|
||||
Unique
|
||||
Unique,
|
||||
BelongsToMany
|
||||
} from "sequelize-typescript";
|
||||
import Queue from "./Queue";
|
||||
import Ticket from "./Ticket";
|
||||
import WhatsappQueue from "./WhatsappQueue";
|
||||
|
||||
@Table
|
||||
class Whatsapp extends Model<Whatsapp> {
|
||||
@@ -44,6 +47,9 @@ class Whatsapp extends Model<Whatsapp> {
|
||||
@Column
|
||||
retries: number;
|
||||
|
||||
@Column(DataType.TEXT)
|
||||
greetingMessage: string;
|
||||
|
||||
@Default(false)
|
||||
@AllowNull
|
||||
@Column
|
||||
@@ -57,6 +63,12 @@ class Whatsapp extends Model<Whatsapp> {
|
||||
|
||||
@HasMany(() => Ticket)
|
||||
tickets: Ticket[];
|
||||
|
||||
@BelongsToMany(() => Queue, () => WhatsappQueue)
|
||||
queues: Array<Queue & { WhatsappQueue: WhatsappQueue }>;
|
||||
|
||||
@HasMany(() => WhatsappQueue)
|
||||
whatsappQueues: WhatsappQueue[];
|
||||
}
|
||||
|
||||
export default Whatsapp;
|
||||
|
||||
33
backend/src/models/WhatsappQueue.ts
Normal file
33
backend/src/models/WhatsappQueue.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
Model,
|
||||
ForeignKey,
|
||||
BelongsTo
|
||||
} from "sequelize-typescript";
|
||||
import Queue from "./Queue";
|
||||
import Whatsapp from "./Whatsapp";
|
||||
|
||||
@Table
|
||||
class WhatsappQueue extends Model<WhatsappQueue> {
|
||||
@ForeignKey(() => Whatsapp)
|
||||
@Column
|
||||
whatsappId: number;
|
||||
|
||||
@ForeignKey(() => Queue)
|
||||
@Column
|
||||
queueId: number;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date;
|
||||
|
||||
@BelongsTo(() => Queue)
|
||||
queue: Queue;
|
||||
}
|
||||
|
||||
export default WhatsappQueue;
|
||||
@@ -8,6 +8,7 @@ import ticketRoutes from "./ticketRoutes";
|
||||
import whatsappRoutes from "./whatsappRoutes";
|
||||
import messageRoutes from "./messageRoutes";
|
||||
import whatsappSessionRoutes from "./whatsappSessionRoutes";
|
||||
import queueRoutes from "./queueRoutes";
|
||||
|
||||
const routes = Router();
|
||||
|
||||
@@ -20,5 +21,6 @@ routes.use(whatsappRoutes);
|
||||
routes.use(messageRoutes);
|
||||
routes.use(messageRoutes);
|
||||
routes.use(whatsappSessionRoutes);
|
||||
routes.use(queueRoutes);
|
||||
|
||||
export default routes;
|
||||
|
||||
18
backend/src/routes/queueRoutes.ts
Normal file
18
backend/src/routes/queueRoutes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from "express";
|
||||
import isAuth from "../middleware/isAuth";
|
||||
|
||||
import * as QueueController from "../controllers/QueueController";
|
||||
|
||||
const queueRoutes = Router();
|
||||
|
||||
queueRoutes.get("/queue", isAuth, QueueController.index);
|
||||
|
||||
queueRoutes.post("/queue", isAuth, QueueController.store);
|
||||
|
||||
queueRoutes.get("/queue/:queueId", isAuth, QueueController.show);
|
||||
|
||||
queueRoutes.put("/queue/:queueId", isAuth, QueueController.update);
|
||||
|
||||
queueRoutes.delete("/queue/:queueId", isAuth, QueueController.remove);
|
||||
|
||||
export default queueRoutes;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { verify } from "jsonwebtoken";
|
||||
|
||||
import User from "../../models/User";
|
||||
import AppError from "../../errors/AppError";
|
||||
import ShowUserService from "../UserServices/ShowUserService";
|
||||
import authConfig from "../../config/auth";
|
||||
@@ -13,6 +15,7 @@ interface RefreshTokenPayload {
|
||||
}
|
||||
|
||||
interface Response {
|
||||
user: User;
|
||||
newToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -37,5 +40,5 @@ export const RefreshTokenService = async (token: string): Promise<Response> => {
|
||||
const newToken = createAccessToken(user);
|
||||
const refreshToken = createRefreshToken(user);
|
||||
|
||||
return { newToken, refreshToken };
|
||||
return { user, newToken, refreshToken };
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const CreateMessageService = async ({
|
||||
{
|
||||
model: Ticket,
|
||||
as: "ticket",
|
||||
include: ["contact"]
|
||||
include: ["contact", "queue"]
|
||||
},
|
||||
{
|
||||
model: Message,
|
||||
|
||||
67
backend/src/services/QueueService/CreateQueueService.ts
Normal file
67
backend/src/services/QueueService/CreateQueueService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as Yup from "yup";
|
||||
import AppError from "../../errors/AppError";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
interface QueueData {
|
||||
name: string;
|
||||
color: string;
|
||||
greetingMessage?: string;
|
||||
}
|
||||
|
||||
const CreateQueueService = async (queueData: QueueData): Promise<Queue> => {
|
||||
const { color, name } = queueData;
|
||||
|
||||
const queueSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "ERR_QUEUE_INVALID_NAME")
|
||||
.required("ERR_QUEUE_INVALID_NAME")
|
||||
.test(
|
||||
"Check-unique-name",
|
||||
"ERR_QUEUE_NAME_ALREADY_EXISTS",
|
||||
async value => {
|
||||
if (value) {
|
||||
const queueWithSameName = await Queue.findOne({
|
||||
where: { name: value }
|
||||
});
|
||||
|
||||
return !queueWithSameName;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
),
|
||||
color: Yup.string()
|
||||
.required("ERR_QUEUE_INVALID_COLOR")
|
||||
.test("Check-color", "ERR_QUEUE_INVALID_COLOR", async value => {
|
||||
if (value) {
|
||||
const colorTestRegex = /^#[0-9a-f]{3,6}$/i;
|
||||
return colorTestRegex.test(value);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.test(
|
||||
"Check-color-exists",
|
||||
"ERR_QUEUE_COLOR_ALREADY_EXISTS",
|
||||
async value => {
|
||||
if (value) {
|
||||
const queueWithSameColor = await Queue.findOne({
|
||||
where: { color: value }
|
||||
});
|
||||
return !queueWithSameColor;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
try {
|
||||
await queueSchema.validate({ color, name });
|
||||
} catch (err) {
|
||||
throw new AppError(err.message);
|
||||
}
|
||||
|
||||
const queue = await Queue.create(queueData);
|
||||
|
||||
return queue;
|
||||
};
|
||||
|
||||
export default CreateQueueService;
|
||||
9
backend/src/services/QueueService/DeleteQueueService.ts
Normal file
9
backend/src/services/QueueService/DeleteQueueService.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import ShowQueueService from "./ShowQueueService";
|
||||
|
||||
const DeleteQueueService = async (queueId: number | string): Promise<void> => {
|
||||
const queue = await ShowQueueService(queueId);
|
||||
|
||||
await queue.destroy();
|
||||
};
|
||||
|
||||
export default DeleteQueueService;
|
||||
9
backend/src/services/QueueService/ListQueuesService.ts
Normal file
9
backend/src/services/QueueService/ListQueuesService.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
const ListQueuesService = async (): Promise<Queue[]> => {
|
||||
const queues = await Queue.findAll({ order: [["name", "ASC"]] });
|
||||
|
||||
return queues;
|
||||
};
|
||||
|
||||
export default ListQueuesService;
|
||||
14
backend/src/services/QueueService/ShowQueueService.ts
Normal file
14
backend/src/services/QueueService/ShowQueueService.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import AppError from "../../errors/AppError";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
const ShowQueueService = async (queueId: number | string): Promise<Queue> => {
|
||||
const queue = await Queue.findByPk(queueId);
|
||||
|
||||
if (!queue) {
|
||||
throw new AppError("ERR_QUEUE_NOT_FOUND");
|
||||
}
|
||||
|
||||
return queue;
|
||||
};
|
||||
|
||||
export default ShowQueueService;
|
||||
73
backend/src/services/QueueService/UpdateQueueService.ts
Normal file
73
backend/src/services/QueueService/UpdateQueueService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Op } from "sequelize";
|
||||
import * as Yup from "yup";
|
||||
import AppError from "../../errors/AppError";
|
||||
import Queue from "../../models/Queue";
|
||||
import ShowQueueService from "./ShowQueueService";
|
||||
|
||||
interface QueueData {
|
||||
name?: string;
|
||||
color?: string;
|
||||
greetingMessage?: string;
|
||||
}
|
||||
|
||||
const UpdateQueueService = async (
|
||||
queueId: number | string,
|
||||
queueData: QueueData
|
||||
): Promise<Queue> => {
|
||||
const { color, name } = queueData;
|
||||
|
||||
const queueSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "ERR_QUEUE_INVALID_NAME")
|
||||
.test(
|
||||
"Check-unique-name",
|
||||
"ERR_QUEUE_NAME_ALREADY_EXISTS",
|
||||
async value => {
|
||||
if (value) {
|
||||
const queueWithSameName = await Queue.findOne({
|
||||
where: { name: value, id: { [Op.not]: queueId } }
|
||||
});
|
||||
|
||||
return !queueWithSameName;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
),
|
||||
color: Yup.string()
|
||||
.required("ERR_QUEUE_INVALID_COLOR")
|
||||
.test("Check-color", "ERR_QUEUE_INVALID_COLOR", async value => {
|
||||
if (value) {
|
||||
const colorTestRegex = /^#[0-9a-f]{3,6}$/i;
|
||||
return colorTestRegex.test(value);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.test(
|
||||
"Check-color-exists",
|
||||
"ERR_QUEUE_COLOR_ALREADY_EXISTS",
|
||||
async value => {
|
||||
if (value) {
|
||||
const queueWithSameColor = await Queue.findOne({
|
||||
where: { color: value, id: { [Op.not]: queueId } }
|
||||
});
|
||||
return !queueWithSameColor;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
try {
|
||||
await queueSchema.validate({ color, name });
|
||||
} catch (err) {
|
||||
throw new AppError(err.message);
|
||||
}
|
||||
|
||||
const queue = await ShowQueueService(queueId);
|
||||
|
||||
await queue.update(queueData);
|
||||
|
||||
return queue;
|
||||
};
|
||||
|
||||
export default UpdateQueueService;
|
||||
@@ -16,23 +16,19 @@ const FindOrCreateTicketService = async (
|
||||
[Op.or]: ["open", "pending"]
|
||||
},
|
||||
contactId: groupContact ? groupContact.id : contact.id
|
||||
},
|
||||
include: ["contact"]
|
||||
}
|
||||
});
|
||||
|
||||
if (ticket) {
|
||||
await ticket.update({ unreadMessages });
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
if (groupContact) {
|
||||
if (!ticket && groupContact) {
|
||||
ticket = await Ticket.findOne({
|
||||
where: {
|
||||
contactId: groupContact.id
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: ["contact"]
|
||||
order: [["updatedAt", "DESC"]]
|
||||
});
|
||||
|
||||
if (ticket) {
|
||||
@@ -41,10 +37,10 @@ const FindOrCreateTicketService = async (
|
||||
userId: null,
|
||||
unreadMessages
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!ticket && !groupContact) {
|
||||
ticket = await Ticket.findOne({
|
||||
where: {
|
||||
updatedAt: {
|
||||
@@ -52,8 +48,7 @@ const FindOrCreateTicketService = async (
|
||||
},
|
||||
contactId: contact.id
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: ["contact"]
|
||||
order: [["updatedAt", "DESC"]]
|
||||
});
|
||||
|
||||
if (ticket) {
|
||||
@@ -62,20 +57,20 @@ const FindOrCreateTicketService = async (
|
||||
userId: null,
|
||||
unreadMessages
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
}
|
||||
|
||||
const { id } = await Ticket.create({
|
||||
contactId: groupContact ? groupContact.id : contact.id,
|
||||
status: "pending",
|
||||
isGroup: !!groupContact,
|
||||
unreadMessages,
|
||||
whatsappId
|
||||
});
|
||||
if (!ticket) {
|
||||
ticket = await Ticket.create({
|
||||
contactId: groupContact ? groupContact.id : contact.id,
|
||||
status: "pending",
|
||||
isGroup: !!groupContact,
|
||||
unreadMessages,
|
||||
whatsappId
|
||||
});
|
||||
}
|
||||
|
||||
ticket = await ShowTicketService(id);
|
||||
ticket = await ShowTicketService(ticket.id);
|
||||
|
||||
return ticket;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { startOfDay, endOfDay, parseISO } from "date-fns";
|
||||
import Ticket from "../../models/Ticket";
|
||||
import Contact from "../../models/Contact";
|
||||
import Message from "../../models/Message";
|
||||
import Queue from "../../models/Queue";
|
||||
import ShowUserService from "../UserServices/ShowUserService";
|
||||
|
||||
interface Request {
|
||||
searchParam?: string;
|
||||
@@ -13,6 +15,7 @@ interface Request {
|
||||
showAll?: string;
|
||||
userId: string;
|
||||
withUnreadMessages?: string;
|
||||
queueIds: number[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
@@ -24,6 +27,7 @@ interface Response {
|
||||
const ListTicketsService = async ({
|
||||
searchParam = "",
|
||||
pageNumber = "1",
|
||||
queueIds,
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
@@ -31,7 +35,8 @@ const ListTicketsService = async ({
|
||||
withUnreadMessages
|
||||
}: Request): Promise<Response> => {
|
||||
let whereCondition: Filterable["where"] = {
|
||||
[Op.or]: [{ userId }, { status: "pending" }]
|
||||
[Op.or]: [{ userId }, { status: "pending" }],
|
||||
queueId: { [Op.or]: [queueIds, null] }
|
||||
};
|
||||
let includeCondition: Includeable[];
|
||||
|
||||
@@ -40,11 +45,16 @@ const ListTicketsService = async ({
|
||||
model: Contact,
|
||||
as: "contact",
|
||||
attributes: ["id", "name", "number", "profilePicUrl"]
|
||||
},
|
||||
{
|
||||
model: Queue,
|
||||
as: "queue",
|
||||
attributes: ["id", "name", "color"]
|
||||
}
|
||||
];
|
||||
|
||||
if (showAll === "true") {
|
||||
whereCondition = {};
|
||||
whereCondition = { queueId: { [Op.or]: [queueIds, null] } };
|
||||
}
|
||||
|
||||
if (status) {
|
||||
@@ -76,10 +86,11 @@ const ListTicketsService = async ({
|
||||
];
|
||||
|
||||
whereCondition = {
|
||||
...whereCondition,
|
||||
[Op.or]: [
|
||||
{
|
||||
"$contact.name$": where(
|
||||
fn("LOWER", col("name")),
|
||||
fn("LOWER", col("contact.name")),
|
||||
"LIKE",
|
||||
`%${sanitizedSearchParam}%`
|
||||
)
|
||||
@@ -106,18 +117,14 @@ const ListTicketsService = async ({
|
||||
}
|
||||
|
||||
if (withUnreadMessages === "true") {
|
||||
includeCondition = [
|
||||
...includeCondition,
|
||||
{
|
||||
model: Message,
|
||||
as: "messages",
|
||||
attributes: [],
|
||||
where: {
|
||||
read: false,
|
||||
fromMe: false
|
||||
}
|
||||
}
|
||||
];
|
||||
const user = await ShowUserService(userId);
|
||||
const userQueueIds = user.queues.map(queue => queue.id);
|
||||
|
||||
whereCondition = {
|
||||
[Op.or]: [{ userId }, { status: "pending" }],
|
||||
queueId: { [Op.or]: [userQueueIds, null] },
|
||||
unreadMessages: { [Op.gt]: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const limit = 20;
|
||||
|
||||
@@ -2,6 +2,7 @@ import Ticket from "../../models/Ticket";
|
||||
import AppError from "../../errors/AppError";
|
||||
import Contact from "../../models/Contact";
|
||||
import User from "../../models/User";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
const ShowTicketService = async (id: string | number): Promise<Ticket> => {
|
||||
const ticket = await Ticket.findByPk(id, {
|
||||
@@ -16,6 +17,11 @@ const ShowTicketService = async (id: string | number): Promise<Ticket> => {
|
||||
model: User,
|
||||
as: "user",
|
||||
attributes: ["id", "name"]
|
||||
},
|
||||
{
|
||||
model: Queue,
|
||||
as: "queue",
|
||||
attributes: ["id", "name", "color"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import AppError from "../../errors/AppError";
|
||||
import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets";
|
||||
import SetTicketMessagesAsRead from "../../helpers/SetTicketMessagesAsRead";
|
||||
import Contact from "../../models/Contact";
|
||||
import Ticket from "../../models/Ticket";
|
||||
import User from "../../models/User";
|
||||
import ShowTicketService from "./ShowTicketService";
|
||||
|
||||
interface TicketData {
|
||||
status?: string;
|
||||
@@ -27,25 +25,7 @@ const UpdateTicketService = async ({
|
||||
}: Request): Promise<Response> => {
|
||||
const { status, userId } = ticketData;
|
||||
|
||||
const ticket = await Ticket.findOne({
|
||||
where: { id: ticketId },
|
||||
include: [
|
||||
{
|
||||
model: Contact,
|
||||
as: "contact",
|
||||
attributes: ["id", "name", "number", "profilePicUrl"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
attributes: ["id", "name"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new AppError("ERR_NO_TICKET_FOUND", 404);
|
||||
}
|
||||
const ticket = await ShowTicketService(ticketId);
|
||||
|
||||
await SetTicketMessagesAsRead(ticket);
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@ import {
|
||||
createAccessToken,
|
||||
createRefreshToken
|
||||
} from "../../helpers/CreateTokens";
|
||||
import { SerializeUser } from "../../helpers/SerializeUser";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
interface SerializedUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
profile: string;
|
||||
queues: Queue[];
|
||||
}
|
||||
|
||||
interface Request {
|
||||
email: string;
|
||||
@@ -11,7 +21,7 @@ interface Request {
|
||||
}
|
||||
|
||||
interface Response {
|
||||
user: User;
|
||||
serializedUser: SerializedUser;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -21,7 +31,8 @@ const AuthUserService = async ({
|
||||
password
|
||||
}: Request): Promise<Response> => {
|
||||
const user = await User.findOne({
|
||||
where: { email }
|
||||
where: { email },
|
||||
include: ["queues"]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -35,8 +46,10 @@ const AuthUserService = async ({
|
||||
const token = createAccessToken(user);
|
||||
const refreshToken = createRefreshToken(user);
|
||||
|
||||
const serializedUser = SerializeUser(user);
|
||||
|
||||
return {
|
||||
user,
|
||||
serializedUser,
|
||||
token,
|
||||
refreshToken
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as Yup from "yup";
|
||||
|
||||
import AppError from "../../errors/AppError";
|
||||
import { SerializeUser } from "../../helpers/SerializeUser";
|
||||
import User from "../../models/User";
|
||||
|
||||
interface Request {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
queueIds?: number[];
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ const CreateUserService = async ({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
queueIds = [],
|
||||
profile = "admin"
|
||||
}: Request): Promise<Response> => {
|
||||
const schema = Yup.object().shape({
|
||||
@@ -47,19 +50,21 @@ const CreateUserService = async ({
|
||||
throw new AppError(err.message);
|
||||
}
|
||||
|
||||
const user = await User.create({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
profile
|
||||
});
|
||||
const user = await User.create(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
profile
|
||||
},
|
||||
{ include: ["queues"] }
|
||||
);
|
||||
|
||||
const serializedUser = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profile: user.profile
|
||||
};
|
||||
await user.$set("queues", queueIds);
|
||||
|
||||
await user.reload();
|
||||
|
||||
const serializedUser = SerializeUser(user);
|
||||
|
||||
return serializedUser;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Sequelize, Op } from "sequelize";
|
||||
import Queue from "../../models/Queue";
|
||||
import User from "../../models/User";
|
||||
|
||||
interface Request {
|
||||
@@ -19,8 +20,8 @@ const ListUsersService = async ({
|
||||
const whereCondition = {
|
||||
[Op.or]: [
|
||||
{
|
||||
name: Sequelize.where(
|
||||
Sequelize.fn("LOWER", Sequelize.col("name")),
|
||||
"$User.name$": Sequelize.where(
|
||||
Sequelize.fn("LOWER", Sequelize.col("User.name")),
|
||||
"LIKE",
|
||||
`%${searchParam.toLowerCase()}%`
|
||||
)
|
||||
@@ -33,10 +34,13 @@ const ListUsersService = async ({
|
||||
|
||||
const { count, rows: users } = await User.findAndCountAll({
|
||||
where: whereCondition,
|
||||
attributes: ["name", "id", "email", "profile"],
|
||||
attributes: ["name", "id", "email", "profile", "createdAt"],
|
||||
limit,
|
||||
offset,
|
||||
order: [["createdAt", "DESC"]]
|
||||
order: [["createdAt", "DESC"]],
|
||||
include: [
|
||||
{ model: Queue, as: "queues", attributes: ["id", "name", "color"] }
|
||||
]
|
||||
});
|
||||
|
||||
const hasMore = count > offset + users.length;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import User from "../../models/User";
|
||||
import AppError from "../../errors/AppError";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
const ShowUserService = async (id: string | number): Promise<User> => {
|
||||
const user = await User.findByPk(id, {
|
||||
attributes: ["name", "id", "email", "profile", "tokenVersion"]
|
||||
attributes: ["name", "id", "email", "profile", "tokenVersion"],
|
||||
include: [
|
||||
{ model: Queue, as: "queues", attributes: ["id", "name", "color"] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as Yup from "yup";
|
||||
|
||||
import AppError from "../../errors/AppError";
|
||||
import User from "../../models/User";
|
||||
import ShowUserService from "./ShowUserService";
|
||||
|
||||
interface UserData {
|
||||
email?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
queueIds?: number[];
|
||||
}
|
||||
|
||||
interface Request {
|
||||
@@ -26,14 +27,7 @@ const UpdateUserService = async ({
|
||||
userData,
|
||||
userId
|
||||
}: Request): Promise<Response | undefined> => {
|
||||
const user = await User.findOne({
|
||||
where: { id: userId },
|
||||
attributes: ["name", "id", "email", "profile"]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError("ERR_NO_USER_FOUND", 404);
|
||||
}
|
||||
const user = await ShowUserService(userId);
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().min(2),
|
||||
@@ -42,7 +36,7 @@ const UpdateUserService = async ({
|
||||
password: Yup.string()
|
||||
});
|
||||
|
||||
const { email, password, profile, name } = userData;
|
||||
const { email, password, profile, name, queueIds = [] } = userData;
|
||||
|
||||
try {
|
||||
await schema.validate({ email, password, profile, name });
|
||||
@@ -57,11 +51,16 @@ const UpdateUserService = async ({
|
||||
name
|
||||
});
|
||||
|
||||
await user.$set("queues", queueIds);
|
||||
|
||||
await user.reload();
|
||||
|
||||
const serializedUser = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profile: user.profile
|
||||
profile: user.profile,
|
||||
queues: user.queues
|
||||
};
|
||||
|
||||
return serializedUser;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService";
|
||||
import { StartWhatsAppSession } from "./StartWhatsAppSession";
|
||||
|
||||
export const StartAllWhatsAppsSessions = async (): Promise<void> => {
|
||||
const whatsapps = await Whatsapp.findAll();
|
||||
const whatsapps = await ListWhatsAppsService();
|
||||
if (whatsapps.length > 0) {
|
||||
whatsapps.forEach(whatsapp => {
|
||||
StartWhatsAppSession(whatsapp);
|
||||
|
||||
@@ -19,6 +19,8 @@ import CreateMessageService from "../MessageServices/CreateMessageService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import CreateOrUpdateContactService from "../ContactServices/CreateOrUpdateContactService";
|
||||
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";
|
||||
import ShowWhatsAppService from "../WhatsappService/ShowWhatsAppService";
|
||||
import { debounce } from "../../helpers/Debounce";
|
||||
|
||||
interface Session extends Client {
|
||||
id?: number;
|
||||
@@ -126,6 +128,60 @@ const verifyMessage = async (
|
||||
await CreateMessageService({ messageData });
|
||||
};
|
||||
|
||||
const verifyQueue = async (
|
||||
wbot: Session,
|
||||
msg: WbotMessage,
|
||||
ticket: Ticket,
|
||||
contact: Contact
|
||||
) => {
|
||||
const { queues, greetingMessage } = await ShowWhatsAppService(wbot.id!);
|
||||
|
||||
if (queues.length === 1) {
|
||||
await ticket.$set("queue", queues[0]);
|
||||
// TODO sendTicketQueueUpdate to frontend
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = msg.body[0];
|
||||
|
||||
const choosenQueue = queues[+selectedOption - 1];
|
||||
|
||||
if (choosenQueue) {
|
||||
await ticket.$set("queue", choosenQueue);
|
||||
|
||||
const body = `\u200e${choosenQueue.greetingMessage}`;
|
||||
|
||||
const sentMessage = await wbot.sendMessage(`${contact.number}@c.us`, body);
|
||||
|
||||
await verifyMessage(sentMessage, ticket, contact);
|
||||
|
||||
// TODO sendTicketQueueUpdate to frontend
|
||||
} else {
|
||||
let options = "";
|
||||
|
||||
queues.forEach((queue, index) => {
|
||||
options += `*${index + 1}* - ${queue.name}\n`;
|
||||
});
|
||||
|
||||
const body = `\u200e${greetingMessage}\n${options}`;
|
||||
|
||||
const debouncedSentMessage = debounce(
|
||||
async () => {
|
||||
const sentMessage = await wbot.sendMessage(
|
||||
`${contact.number}@c.us`,
|
||||
body
|
||||
);
|
||||
verifyMessage(sentMessage, ticket, contact);
|
||||
},
|
||||
3000,
|
||||
ticket.id
|
||||
);
|
||||
|
||||
debouncedSentMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const isValidMsg = (msg: WbotMessage): boolean => {
|
||||
if (msg.from === "status@broadcast") return false;
|
||||
if (
|
||||
@@ -146,66 +202,66 @@ const handleMessage = async (
|
||||
msg: WbotMessage,
|
||||
wbot: Session
|
||||
): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
(async () => {
|
||||
if (!isValidMsg(msg)) {
|
||||
return;
|
||||
if (!isValidMsg(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let msgContact: WbotContact;
|
||||
let groupContact: Contact | undefined;
|
||||
|
||||
if (msg.fromMe) {
|
||||
// messages sent automatically by wbot have a special character in front of it
|
||||
// if so, this message was already been stored in database;
|
||||
if (/\u200e/.test(msg.body[0])) return;
|
||||
|
||||
// media messages sent from me from cell phone, first comes with "hasMedia = false" and type = "image/ptt/etc"
|
||||
// in this case, return and let this message be handled by "media_uploaded" event, when it will have "hasMedia = true"
|
||||
|
||||
if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard") return;
|
||||
|
||||
msgContact = await wbot.getContactById(msg.to);
|
||||
} else {
|
||||
msgContact = await msg.getContact();
|
||||
}
|
||||
|
||||
const chat = await msg.getChat();
|
||||
|
||||
if (chat.isGroup) {
|
||||
let msgGroupContact;
|
||||
|
||||
if (msg.fromMe) {
|
||||
msgGroupContact = await wbot.getContactById(msg.to);
|
||||
} else {
|
||||
msgGroupContact = await wbot.getContactById(msg.from);
|
||||
}
|
||||
|
||||
try {
|
||||
let msgContact: WbotContact;
|
||||
let groupContact: Contact | undefined;
|
||||
groupContact = await verifyContact(msgGroupContact);
|
||||
}
|
||||
|
||||
if (msg.fromMe) {
|
||||
// media messages sent from me from cell phone, first comes with "hasMedia = false" and type = "image/ptt/etc"
|
||||
// in this case, return and let this message be handled by "media_uploaded" event, when it will have "hasMedia = true"
|
||||
const unreadMessages = msg.fromMe ? 0 : chat.unreadCount;
|
||||
|
||||
if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard")
|
||||
return;
|
||||
const contact = await verifyContact(msgContact);
|
||||
const ticket = await FindOrCreateTicketService(
|
||||
contact,
|
||||
wbot.id!,
|
||||
unreadMessages,
|
||||
groupContact
|
||||
);
|
||||
|
||||
msgContact = await wbot.getContactById(msg.to);
|
||||
} else {
|
||||
msgContact = await msg.getContact();
|
||||
}
|
||||
if (msg.hasMedia) {
|
||||
await verifyMediaMessage(msg, ticket, contact);
|
||||
} else {
|
||||
await verifyMessage(msg, ticket, contact);
|
||||
}
|
||||
|
||||
const chat = await msg.getChat();
|
||||
|
||||
if (chat.isGroup) {
|
||||
let msgGroupContact;
|
||||
|
||||
if (msg.fromMe) {
|
||||
msgGroupContact = await wbot.getContactById(msg.to);
|
||||
} else {
|
||||
msgGroupContact = await wbot.getContactById(msg.from);
|
||||
}
|
||||
|
||||
groupContact = await verifyContact(msgGroupContact);
|
||||
}
|
||||
|
||||
const unreadMessages = msg.fromMe ? 0 : chat.unreadCount;
|
||||
|
||||
const contact = await verifyContact(msgContact);
|
||||
const ticket = await FindOrCreateTicketService(
|
||||
contact,
|
||||
wbot.id!,
|
||||
unreadMessages,
|
||||
groupContact
|
||||
);
|
||||
|
||||
if (msg.hasMedia) {
|
||||
await verifyMediaMessage(msg, ticket, contact);
|
||||
resolve();
|
||||
} else {
|
||||
await verifyMessage(msg, ticket, contact);
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
logger.error(`Error handling whatsapp message: Err: ${err}`);
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
if (!ticket.queue && !chat.isGroup && !msg.fromMe && !ticket.userId) {
|
||||
await verifyQueue(wbot, msg, ticket, contact);
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
logger.error(`Error handling whatsapp message: Err: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMsgAck = async (msg: WbotMessage, ack: MessageAck) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
|
||||
const AssociateWhatsappQueue = async (
|
||||
whatsapp: Whatsapp,
|
||||
queueIds: number[]
|
||||
): Promise<void> => {
|
||||
await whatsapp.$set("queues", queueIds);
|
||||
|
||||
await whatsapp.reload();
|
||||
};
|
||||
|
||||
export default AssociateWhatsappQueue;
|
||||
@@ -2,9 +2,12 @@ import * as Yup from "yup";
|
||||
|
||||
import AppError from "../../errors/AppError";
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
|
||||
|
||||
interface Request {
|
||||
name: string;
|
||||
queueIds?: number[];
|
||||
greetingMessage?: string;
|
||||
status?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -17,6 +20,8 @@ interface Response {
|
||||
const CreateWhatsAppService = async ({
|
||||
name,
|
||||
status = "OPENING",
|
||||
queueIds = [],
|
||||
greetingMessage,
|
||||
isDefault = false
|
||||
}: Request): Promise<Response> => {
|
||||
const schema = Yup.object().shape({
|
||||
@@ -62,11 +67,21 @@ const CreateWhatsAppService = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const whatsapp = await Whatsapp.create({
|
||||
name,
|
||||
status,
|
||||
isDefault
|
||||
});
|
||||
if (queueIds.length > 1 && !greetingMessage) {
|
||||
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
|
||||
}
|
||||
|
||||
const whatsapp = await Whatsapp.create(
|
||||
{
|
||||
name,
|
||||
status,
|
||||
greetingMessage,
|
||||
isDefault
|
||||
},
|
||||
{ include: ["queues"] }
|
||||
);
|
||||
|
||||
await AssociateWhatsappQueue(whatsapp, queueIds);
|
||||
|
||||
return { whatsapp, oldDefaultWhatsapp };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import Queue from "../../models/Queue";
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
|
||||
const ListWhatsAppsService = async (): Promise<Whatsapp[]> => {
|
||||
const whatsapps = await Whatsapp.findAll();
|
||||
const whatsapps = await Whatsapp.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Queue,
|
||||
as: "queues",
|
||||
attributes: ["id", "name", "color", "greetingMessage"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return whatsapps;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
import AppError from "../../errors/AppError";
|
||||
import Queue from "../../models/Queue";
|
||||
|
||||
const ShowWhatsAppService = async (id: string | number): Promise<Whatsapp> => {
|
||||
const whatsapp = await Whatsapp.findByPk(id);
|
||||
const whatsapp = await Whatsapp.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: Queue,
|
||||
as: "queues",
|
||||
attributes: ["id", "name", "color", "greetingMessage"]
|
||||
}
|
||||
],
|
||||
order: [["queues", "name", "ASC"]]
|
||||
});
|
||||
|
||||
if (!whatsapp) {
|
||||
throw new AppError("ERR_NO_WAPP_FOUND", 404);
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Op } from "sequelize";
|
||||
|
||||
import AppError from "../../errors/AppError";
|
||||
import Whatsapp from "../../models/Whatsapp";
|
||||
import ShowWhatsAppService from "./ShowWhatsAppService";
|
||||
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
|
||||
|
||||
interface WhatsappData {
|
||||
name?: string;
|
||||
status?: string;
|
||||
session?: string;
|
||||
isDefault?: boolean;
|
||||
greetingMessage?: string;
|
||||
queueIds?: number[];
|
||||
}
|
||||
|
||||
interface Request {
|
||||
@@ -26,11 +30,18 @@ const UpdateWhatsAppService = async ({
|
||||
whatsappId
|
||||
}: Request): Promise<Response> => {
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().min(2),
|
||||
name: Yup.string().min(2).required(),
|
||||
isDefault: Yup.boolean()
|
||||
});
|
||||
|
||||
const { name, status, isDefault, session } = whatsappData;
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
isDefault,
|
||||
session,
|
||||
greetingMessage,
|
||||
queueIds = []
|
||||
} = whatsappData;
|
||||
|
||||
try {
|
||||
await schema.validate({ name, status, isDefault });
|
||||
@@ -38,6 +49,10 @@ const UpdateWhatsAppService = async ({
|
||||
throw new AppError(err.message);
|
||||
}
|
||||
|
||||
if (queueIds.length > 1 && !greetingMessage) {
|
||||
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
|
||||
}
|
||||
|
||||
let oldDefaultWhatsapp: Whatsapp | null = null;
|
||||
|
||||
if (isDefault) {
|
||||
@@ -49,20 +64,18 @@ const UpdateWhatsAppService = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const whatsapp = await Whatsapp.findOne({
|
||||
where: { id: whatsappId }
|
||||
});
|
||||
const whatsapp = await ShowWhatsAppService(whatsappId);
|
||||
|
||||
if (!whatsapp) {
|
||||
throw new AppError("ERR_NO_WAPP_FOUND", 404);
|
||||
}
|
||||
await whatsapp.update({
|
||||
name,
|
||||
status,
|
||||
session,
|
||||
greetingMessage,
|
||||
isDefault
|
||||
});
|
||||
|
||||
await AssociateWhatsappQueue(whatsapp, queueIds);
|
||||
|
||||
return { whatsapp, oldDefaultWhatsapp };
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"mic-recorder-to-mp3": "^2.2.2",
|
||||
"qrcode.react": "^1.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-modal-image": "^2.5.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
<head>
|
||||
<title>WhaTicket</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel=”shortcut icon” href=”%PUBLIC_URL%/favicon.ico”>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
18
frontend/src/accessRules.js
Normal file
18
frontend/src/accessRules.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const rules = {
|
||||
user: {
|
||||
static: [],
|
||||
},
|
||||
|
||||
admin: {
|
||||
static: [
|
||||
"drawer-admin-items:view",
|
||||
"tickets-manager:showall",
|
||||
"user-modal:editProfile",
|
||||
"user-modal:editQueues",
|
||||
"ticket-options:deleteTicket",
|
||||
"contacts-page:deleteContact",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default rules;
|
||||
39
frontend/src/components/Can/index.js
Normal file
39
frontend/src/components/Can/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import rules from "../../accessRules";
|
||||
|
||||
const check = (rules, role, action, data) => {
|
||||
const permissions = rules[role];
|
||||
if (!permissions) {
|
||||
// role is not present in the rules
|
||||
return false;
|
||||
}
|
||||
|
||||
const staticPermissions = permissions.static;
|
||||
|
||||
if (staticPermissions && staticPermissions.includes(action)) {
|
||||
// static rule not provided for action
|
||||
return true;
|
||||
}
|
||||
|
||||
const dynamicPermissions = permissions.dynamic;
|
||||
|
||||
if (dynamicPermissions) {
|
||||
const permissionCondition = dynamicPermissions[action];
|
||||
if (!permissionCondition) {
|
||||
// dynamic rule not provided for action
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissionCondition(data);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const Can = ({ role, perform, data, yes, no }) =>
|
||||
check(rules, role, perform, data) ? yes() : no();
|
||||
|
||||
Can.defaultProps = {
|
||||
yes: () => null,
|
||||
no: () => null,
|
||||
};
|
||||
|
||||
export { Can };
|
||||
25
frontend/src/components/ColorPicker/index.js
Normal file
25
frontend/src/components/ColorPicker/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { GithubPicker } from "react-color";
|
||||
|
||||
const ColorPicker = ({ onChange, currentColor }) => {
|
||||
const [color, setColor] = useState(currentColor);
|
||||
|
||||
const handleChange = color => {
|
||||
setColor(color.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GithubPicker
|
||||
width={"100%"}
|
||||
triangle="hide"
|
||||
color={color}
|
||||
onChange={handleChange}
|
||||
onChangeComplete={color => onChange(color.hex)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -8,11 +8,11 @@ import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
||||
const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={() => onClose(false)}
|
||||
aria-labelledby="confirm-dialog"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog">{title}</DialogTitle>
|
||||
@@ -22,7 +22,7 @@ const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onClose(false)}
|
||||
color="default"
|
||||
>
|
||||
{i18n.t("confirmationModal.buttons.cancel")}
|
||||
@@ -30,7 +30,7 @@ const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onClose(false);
|
||||
onConfirm();
|
||||
}}
|
||||
color="secondary"
|
||||
|
||||
@@ -25,6 +25,8 @@ import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import RecordingTimer from "./RecordingTimer";
|
||||
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
|
||||
@@ -164,7 +166,6 @@ const useStyles = makeStyles(theme => ({
|
||||
const MessageInput = ({ ticketStatus }) => {
|
||||
const classes = useStyles();
|
||||
const { ticketId } = useParams();
|
||||
const username = localStorage.getItem("username");
|
||||
|
||||
const [medias, setMedias] = useState([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
@@ -175,17 +176,9 @@ const MessageInput = ({ ticketStatus }) => {
|
||||
const { setReplyingMessage, replyingMessage } = useContext(
|
||||
ReplyMessageContext
|
||||
);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [signMessage, setSignMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const storedSignOption = localStorage.getItem("signOption");
|
||||
if (storedSignOption === "true") setSignMessage(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("signOption", signMessage);
|
||||
}, [signMessage]);
|
||||
const [signMessage, setSignMessage] = useLocalStorage("signOption", true);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current.focus();
|
||||
@@ -255,7 +248,7 @@ const MessageInput = ({ ticketStatus }) => {
|
||||
fromMe: true,
|
||||
mediaUrl: "",
|
||||
body: signMessage
|
||||
? `*${username}:*\n${inputMessage.trim()}`
|
||||
? `*${user?.name}:*\n${inputMessage.trim()}`
|
||||
: inputMessage.trim(),
|
||||
quotedMsg: replyingMessage,
|
||||
};
|
||||
@@ -279,7 +272,7 @@ const MessageInput = ({ ticketStatus }) => {
|
||||
setRecording(true);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -314,7 +307,7 @@ const MessageInput = ({ ticketStatus }) => {
|
||||
await Mp3Recorder.stop().getMp3();
|
||||
setRecording(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -428,8 +421,8 @@ const MessageInput = ({ ticketStatus }) => {
|
||||
<Switch
|
||||
size="small"
|
||||
checked={signMessage}
|
||||
onChange={() => {
|
||||
setSignMessage(prevState => !prevState);
|
||||
onChange={e => {
|
||||
setSignMessage(e.target.checked);
|
||||
}}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
|
||||
@@ -36,7 +36,7 @@ const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => {
|
||||
<ConfirmationModal
|
||||
title={i18n.t("messageOptionsMenu.confirmationModal.title")}
|
||||
open={confirmationOpen}
|
||||
setOpen={setConfirmationOpen}
|
||||
onClose={setConfirmationOpen}
|
||||
onConfirm={handleDeleteMessage}
|
||||
>
|
||||
{i18n.t("messageOptionsMenu.confirmationModal.message")}
|
||||
|
||||
@@ -301,7 +301,7 @@ const reducer = (state, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
const MessagesList = ({ ticketId, isGroup, setReplyingMessage }) => {
|
||||
const MessagesList = ({ ticketId, isGroup }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [messagesList, dispatch] = useReducer(reducer, []);
|
||||
@@ -354,7 +354,8 @@ const MessagesList = ({ ticketId, isGroup, setReplyingMessage }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
socket.emit("joinChatBox", ticketId);
|
||||
|
||||
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||
|
||||
socket.on("appMessage", data => {
|
||||
if (data.action === "create") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import Button from "@material-ui/core/Button";
|
||||
@@ -18,6 +18,7 @@ import api from "../../services/api";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import ContactModal from "../ContactModal";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const filter = createFilterOptions({
|
||||
trim: true,
|
||||
@@ -25,7 +26,6 @@ const filter = createFilterOptions({
|
||||
|
||||
const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||
const history = useHistory();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
|
||||
const [options, setOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -33,6 +33,7 @@ const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||
const [selectedContact, setSelectedContact] = useState(null);
|
||||
const [newContact, setNewContact] = useState({});
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || searchParam.length < 3) {
|
||||
@@ -71,7 +72,7 @@ const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||
try {
|
||||
const { data: ticket } = await api.post("/tickets", {
|
||||
contactId: contactId,
|
||||
userId: userId,
|
||||
userId: user.id,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { format } from "date-fns";
|
||||
@@ -18,6 +18,7 @@ import TicketListItem from "../TicketListItem";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import useTickets from "../../hooks/useTickets";
|
||||
import alertSound from "../../assets/sound.mp3";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabContainer: {
|
||||
@@ -43,7 +44,7 @@ const NotificationsPopOver = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const history = useHistory();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
const { user } = useContext(AuthContext);
|
||||
const ticketIdUrl = +history.location.pathname.split("/")[2];
|
||||
const ticketIdRef = useRef(ticketIdUrl);
|
||||
const anchorEl = useRef();
|
||||
@@ -56,6 +57,8 @@ const NotificationsPopOver = () => {
|
||||
const [play] = useSound(alertSound);
|
||||
const soundAlertRef = useRef();
|
||||
|
||||
const historyRef = useRef(history);
|
||||
|
||||
useEffect(() => {
|
||||
soundAlertRef.current = play;
|
||||
|
||||
@@ -77,7 +80,7 @@ const NotificationsPopOver = () => {
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.emit("joinNotification");
|
||||
socket.on("connect", () => socket.emit("joinNotification"));
|
||||
|
||||
socket.on("ticket", data => {
|
||||
if (data.action === "updateUnread" || data.action === "delete") {
|
||||
@@ -108,7 +111,7 @@ const NotificationsPopOver = () => {
|
||||
if (
|
||||
data.action === "create" &&
|
||||
!data.message.read &&
|
||||
(data.ticket.userId === userId || !data.ticket.userId)
|
||||
(data.ticket.userId === user?.id || !data.ticket.userId)
|
||||
) {
|
||||
setNotifications(prevState => {
|
||||
const ticketIndex = prevState.findIndex(t => t.id === data.ticket.id);
|
||||
@@ -122,21 +125,21 @@ const NotificationsPopOver = () => {
|
||||
const shouldNotNotificate =
|
||||
(data.message.ticketId === ticketIdRef.current &&
|
||||
document.visibilityState === "visible") ||
|
||||
(data.ticket.userId && data.ticket.userId !== userId) ||
|
||||
(data.ticket.userId && data.ticket.userId !== user?.id) ||
|
||||
data.ticket.isGroup;
|
||||
|
||||
if (shouldNotNotificate) return;
|
||||
|
||||
handleNotifications(data, history);
|
||||
handleNotifications(data);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [history, userId]);
|
||||
}, [user]);
|
||||
|
||||
const handleNotifications = (data, history) => {
|
||||
const handleNotifications = data => {
|
||||
const { message, contact, ticket } = data;
|
||||
|
||||
const options = {
|
||||
@@ -154,7 +157,7 @@ const NotificationsPopOver = () => {
|
||||
notification.onclick = e => {
|
||||
e.preventDefault();
|
||||
window.focus();
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
historyRef.current.push(`/tickets/${ticket.id}`);
|
||||
};
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
|
||||
240
frontend/src/components/QueueModal/index.js
Normal file
240
frontend/src/components/QueueModal/index.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
import ColorPicker from "../ColorPicker";
|
||||
import { InputAdornment } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
marginRight: theme.spacing(1),
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
},
|
||||
colorAdorment: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const QueueSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
color: Yup.string().min(3, "Too Short!").max(9, "Too Long!").required(),
|
||||
greetingMessage: Yup.string(),
|
||||
});
|
||||
|
||||
const QueueModal = ({ open, onClose, queueId }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const initialState = {
|
||||
name: "",
|
||||
color: "",
|
||||
greetingMessage: "",
|
||||
};
|
||||
|
||||
const [queue, setQueue] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!queueId) return;
|
||||
try {
|
||||
const { data } = await api.get(`/queue/${queueId}`);
|
||||
setQueue(prevState => {
|
||||
return { ...prevState, ...data };
|
||||
});
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setQueue({
|
||||
name: "",
|
||||
color: "",
|
||||
greetingMessage: "",
|
||||
});
|
||||
};
|
||||
}, [queueId, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQueue(initialState);
|
||||
};
|
||||
|
||||
const handleSaveQueue = async values => {
|
||||
try {
|
||||
if (queueId) {
|
||||
await api.put(`/queue/${queueId}`, values);
|
||||
} else {
|
||||
await api.post("/queue", values);
|
||||
}
|
||||
toast.success("Queue saved successfully");
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
<DialogTitle>
|
||||
{queueId
|
||||
? `${i18n.t("queueModal.title.edit")}`
|
||||
: `${i18n.t("queueModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={queue}
|
||||
enableReinitialize={true}
|
||||
validationSchema={QueueSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveQueue(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting, values }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.color")}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<div
|
||||
style={{ backgroundColor: values.color }}
|
||||
className={classes.colorAdorment}
|
||||
></div>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
name="color"
|
||||
error={touched.color && Boolean(errors.color)}
|
||||
helperText={touched.color && errors.color}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<ColorPicker
|
||||
label={"Pick Color"}
|
||||
currentColor={queue.color}
|
||||
onChange={color => {
|
||||
values.color = color;
|
||||
setQueue(prevState => {
|
||||
return { ...prevState, color };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.greetingMessage")}
|
||||
type="greetingMessage"
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
name="greetingMessage"
|
||||
error={
|
||||
touched.greetingMessage && Boolean(errors.greetingMessage)
|
||||
}
|
||||
helperText={
|
||||
touched.greetingMessage && errors.greetingMessage
|
||||
}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("queueModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{queueId
|
||||
? `${i18n.t("queueModal.buttons.okEdit")}`
|
||||
: `${i18n.t("queueModal.buttons.okAdd")}`}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueueModal;
|
||||
89
frontend/src/components/QueueSelect/index.js
Normal file
89
frontend/src/components/QueueSelect/index.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import Chip from "@material-ui/core/Chip";
|
||||
import toastError from "../../errors/toastError";
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
chips: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
chip: {
|
||||
margin: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
const QueueSelect = ({ selectedQueueIds, onChange }) => {
|
||||
const classes = useStyles();
|
||||
const [queues, setQueues] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get("/queue");
|
||||
setQueues(data);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleChange = event => {
|
||||
onChange(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<FormControl fullWidth margin="dense" variant="outlined">
|
||||
<InputLabel>Filas</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
labelWidth={40}
|
||||
value={selectedQueueIds}
|
||||
onChange={handleChange}
|
||||
MenuProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
},
|
||||
getContentAnchorEl: null,
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{selected?.length > 0 &&
|
||||
selected.map(id => {
|
||||
const queue = queues.find(q => q.id === id);
|
||||
return queue ? (
|
||||
<Chip
|
||||
key={id}
|
||||
style={{ backgroundColor: queue.color }}
|
||||
variant="outlined"
|
||||
label={queue.name}
|
||||
className={classes.chip}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{queues.map(queue => (
|
||||
<MenuItem key={queue.id} value={queue.id}>
|
||||
{queue.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueueSelect;
|
||||
@@ -86,10 +86,11 @@ const Ticket = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
socket.emit("joinChatBox", ticketId);
|
||||
|
||||
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||
|
||||
socket.on("ticket", data => {
|
||||
if (data.action === "updateStatus") {
|
||||
if (data.action === "update") {
|
||||
setTicket(data.ticket);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
@@ -10,6 +10,7 @@ import api from "../../services/api";
|
||||
import TicketOptionsMenu from "../TicketOptionsMenu";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
actionButtons: {
|
||||
@@ -26,10 +27,10 @@ const useStyles = makeStyles(theme => ({
|
||||
const TicketActionButtons = ({ ticket }) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ticketOptionsMenuOpen = Boolean(anchorEl);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const handleOpenTicketOptionsMenu = e => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
@@ -66,7 +67,7 @@ const TicketActionButtons = ({ ticket }) => {
|
||||
loading={loading}
|
||||
startIcon={<Replay />}
|
||||
size="small"
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", userId)}
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.reopen")}
|
||||
</ButtonWithSpinner>
|
||||
@@ -86,7 +87,7 @@ const TicketActionButtons = ({ ticket }) => {
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={e => handleUpdateTicketStatus(e, "closed", userId)}
|
||||
onClick={e => handleUpdateTicketStatus(e, "closed", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.resolve")}
|
||||
</ButtonWithSpinner>
|
||||
@@ -107,7 +108,7 @@ const TicketActionButtons = ({ ticket }) => {
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", userId)}
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.accept")}
|
||||
</ButtonWithSpinner>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { parseISO, format, isSameDay } from "date-fns";
|
||||
@@ -19,6 +19,8 @@ import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import MarkdownWrapper from "../MarkdownWrapper";
|
||||
import { Tooltip } from "@material-ui/core";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticket: {
|
||||
@@ -87,15 +89,24 @@ const useStyles = makeStyles(theme => ({
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
},
|
||||
|
||||
ticketQueueColor: {
|
||||
flex: "none",
|
||||
width: "8px",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: "0%",
|
||||
left: "0%",
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketListItem = ({ ticket }) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { ticketId } = useParams();
|
||||
const isMounted = useRef(true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -108,7 +119,7 @@ const TicketListItem = ({ ticket }) => {
|
||||
try {
|
||||
await api.put(`/tickets/${ticketId}`, {
|
||||
status: "open",
|
||||
userId: userId,
|
||||
userId: user?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
@@ -138,6 +149,16 @@ const TicketListItem = ({ ticket }) => {
|
||||
[classes.pendingTicket]: ticket.status === "pending",
|
||||
})}
|
||||
>
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="right"
|
||||
title={ticket.queue?.name || "Sem fila"}
|
||||
>
|
||||
<span
|
||||
style={{ backgroundColor: ticket.queue?.color || "#7C7C7C" }}
|
||||
className={classes.ticketQueueColor}
|
||||
></span>
|
||||
</Tooltip>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
src={ticket.contact.profilePicUrl && ticket.contact.profilePicUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import Menu from "@material-ui/core/Menu";
|
||||
@@ -8,11 +8,14 @@ import api from "../../services/api";
|
||||
import ConfirmationModal from "../ConfirmationModal";
|
||||
import TransferTicketModal from "../TransferTicketModal";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { Can } from "../Can";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||
const [transferTicketModalOpen, setTransferTicketModalOpen] = useState(false);
|
||||
const isMounted = useRef(true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -62,9 +65,16 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||
open={menuOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||
{i18n.t("ticketOptionsMenu.delete")}
|
||||
</MenuItem>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="ticket-options:deleteTicket"
|
||||
yes={() => (
|
||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||
{i18n.t("ticketOptionsMenu.delete")}
|
||||
</MenuItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MenuItem onClick={handleOpenTransferModal}>
|
||||
{i18n.t("ticketOptionsMenu.transfer")}
|
||||
</MenuItem>
|
||||
@@ -76,7 +86,7 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||
ticket.contact.name
|
||||
}?`}
|
||||
open={confirmationOpen}
|
||||
setOpen={setConfirmationOpen}
|
||||
onClose={setConfirmationOpen}
|
||||
onConfirm={handleDeleteTicket}
|
||||
>
|
||||
{i18n.t("ticketOptionsMenu.confirmationModal.message")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import React, { useState, useEffect, useReducer, useContext } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
@@ -7,9 +7,11 @@ import Paper from "@material-ui/core/Paper";
|
||||
|
||||
import TicketListItem from "../TicketListItem";
|
||||
import TicketsListSkeleton from "../TicketsListSkeleton";
|
||||
|
||||
import useTickets from "../../hooks/useTickets";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { ListSubheader } from "@material-ui/core";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticketsListWrapper: {
|
||||
@@ -34,6 +36,9 @@ const useStyles = makeStyles(theme => ({
|
||||
zIndex: 2,
|
||||
backgroundColor: "white",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
|
||||
ticketsCount: {
|
||||
@@ -110,14 +115,14 @@ const reducer = (state, action) => {
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_TICKET_MESSAGES_COUNT") {
|
||||
const { ticket, searchParam } = action.payload;
|
||||
if (action.type === "UPDATE_TICKET_UNREAD_MESSAGES") {
|
||||
const ticket = action.payload;
|
||||
|
||||
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex] = ticket;
|
||||
state.unshift(state.splice(ticketIndex, 1)[0]);
|
||||
} else if (!searchParam) {
|
||||
} else {
|
||||
state.unshift(ticket);
|
||||
}
|
||||
|
||||
@@ -148,38 +153,47 @@ const reducer = (state, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
const TicketsList = ({ status, searchParam, showAll }) => {
|
||||
const userId = +localStorage.getItem("userId");
|
||||
const TicketsList = ({ status, searchParam, showAll, selectedQueueIds }) => {
|
||||
const classes = useStyles();
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [ticketsList, dispatch] = useReducer(reducer, []);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [status, searchParam, dispatch, showAll]);
|
||||
}, [status, searchParam, dispatch, showAll, selectedQueueIds]);
|
||||
|
||||
const { tickets, hasMore, loading } = useTickets({
|
||||
pageNumber,
|
||||
searchParam,
|
||||
status,
|
||||
showAll,
|
||||
queueIds: JSON.stringify(selectedQueueIds),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && !searchParam) return;
|
||||
dispatch({
|
||||
type: "LOAD_TICKETS",
|
||||
payload: tickets,
|
||||
});
|
||||
}, [tickets]);
|
||||
}, [tickets, status, searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
if (status) {
|
||||
socket.emit("joinTickets", status);
|
||||
} else {
|
||||
socket.emit("joinNotification");
|
||||
}
|
||||
|
||||
const shouldUpdateTicket = ticket =>
|
||||
(!ticket.userId || ticket.userId === user?.id || showAll) &&
|
||||
(!ticket.queueId || selectedQueueIds.indexOf(ticket.queueId) > -1);
|
||||
|
||||
socket.on("connect", () => {
|
||||
if (status) {
|
||||
socket.emit("joinTickets", status);
|
||||
} else {
|
||||
socket.emit("joinNotification");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("ticket", data => {
|
||||
if (data.action === "updateUnread") {
|
||||
@@ -189,10 +203,7 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(data.action === "updateStatus" || data.action === "create") &&
|
||||
(!data.ticket.userId || data.ticket.userId === userId || showAll)
|
||||
) {
|
||||
if (data.action === "update" && shouldUpdateTicket(data.ticket)) {
|
||||
dispatch({
|
||||
type: "UPDATE_TICKET",
|
||||
payload: data.ticket,
|
||||
@@ -205,16 +216,10 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
||||
});
|
||||
|
||||
socket.on("appMessage", data => {
|
||||
if (
|
||||
data.action === "create" &&
|
||||
(!data.ticket.userId || data.ticket.userId === userId || showAll)
|
||||
) {
|
||||
if (data.action === "create" && shouldUpdateTicket(data.ticket)) {
|
||||
dispatch({
|
||||
type: "UPDATE_TICKET_MESSAGES_COUNT",
|
||||
payload: {
|
||||
ticket: data.ticket,
|
||||
searchParam,
|
||||
},
|
||||
type: "UPDATE_TICKET_UNREAD_MESSAGES",
|
||||
payload: data.ticket,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -231,7 +236,7 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [status, showAll, userId, searchParam]);
|
||||
}, [status, showAll, user, selectedQueueIds]);
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber(prevState => prevState + 1);
|
||||
@@ -259,14 +264,22 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
||||
<List style={{ paddingTop: 0 }}>
|
||||
{status === "open" && (
|
||||
<ListSubheader className={classes.ticketsListHeader}>
|
||||
{i18n.t("ticketsList.assignedHeader")}
|
||||
<span className={classes.ticketsCount}>{ticketsList.length}</span>
|
||||
<div>
|
||||
{i18n.t("ticketsList.assignedHeader")}
|
||||
<span className={classes.ticketsCount}>
|
||||
{ticketsList.length}
|
||||
</span>
|
||||
</div>
|
||||
</ListSubheader>
|
||||
)}
|
||||
{status === "pending" && (
|
||||
<ListSubheader className={classes.ticketsListHeader}>
|
||||
{i18n.t("ticketsList.pendingHeader")}
|
||||
<span className={classes.ticketsCount}>{ticketsList.length}</span>
|
||||
<div>
|
||||
{i18n.t("ticketsList.pendingHeader")}
|
||||
<span className={classes.ticketsCount}>
|
||||
{ticketsList.length}
|
||||
</span>
|
||||
</div>
|
||||
</ListSubheader>
|
||||
)}
|
||||
{ticketsList.length === 0 && !loading ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
@@ -8,8 +8,7 @@ import Tabs from "@material-ui/core/Tabs";
|
||||
import Tab from "@material-ui/core/Tab";
|
||||
import MoveToInboxIcon from "@material-ui/icons/MoveToInbox";
|
||||
import CheckBoxIcon from "@material-ui/icons/CheckBox";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import AddIcon from "@material-ui/icons/Add";
|
||||
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||
import Switch from "@material-ui/core/Switch";
|
||||
|
||||
@@ -18,6 +17,11 @@ import TicketsList from "../TicketsList";
|
||||
import TabPanel from "../TabPanel";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../Can";
|
||||
import TicketsQueueSelect from "../TicketsQueueSelect";
|
||||
import { Button } from "@material-ui/core";
|
||||
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticketsWrapper: {
|
||||
@@ -46,17 +50,12 @@ const useStyles = makeStyles(theme => ({
|
||||
width: 120,
|
||||
},
|
||||
|
||||
ticketsListActions: {
|
||||
flex: "none",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
|
||||
searchBox: {
|
||||
position: "relative",
|
||||
ticketOptionsBox: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "#fafafa",
|
||||
padding: "10px 13px",
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
|
||||
serachInputWrapper: {
|
||||
@@ -65,6 +64,7 @@ const useStyles = makeStyles(theme => ({
|
||||
display: "flex",
|
||||
borderRadius: 40,
|
||||
padding: 4,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
|
||||
searchIcon: {
|
||||
@@ -88,15 +88,35 @@ const TicketsManager = () => {
|
||||
const [tab, setTab] = useState("open");
|
||||
const [newTicketModalOpen, setNewTicketModalOpen] = useState(false);
|
||||
const [showAllTickets, setShowAllTickets] = useState(false);
|
||||
const { user } = useContext(AuthContext);
|
||||
const searchInputRef = useRef();
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useLocalStorage(
|
||||
"selectedQueueIds",
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchContact = e => {
|
||||
if (e.target.value === "") {
|
||||
setSearchParam(e.target.value.toLowerCase());
|
||||
useEffect(() => {
|
||||
if (tab === "search") {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
let searchTimeout;
|
||||
|
||||
const handleSearch = e => {
|
||||
const searchedTerm = e.target.value.toLowerCase();
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
if (searchedTerm === "") {
|
||||
setSearchParam(searchedTerm);
|
||||
setTab("open");
|
||||
return;
|
||||
}
|
||||
setSearchParam(e.target.value.toLowerCase());
|
||||
setTab("search");
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearchParam(searchedTerm);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleChangeTab = (e, newValue) => {
|
||||
@@ -138,50 +158,78 @@ const TicketsManager = () => {
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Paper square elevation={0} className={classes.searchBox}>
|
||||
<div className={classes.serachInputWrapper}>
|
||||
<SearchIcon className={classes.searchIcon} />
|
||||
<InputBase
|
||||
className={classes.searchInput}
|
||||
placeholder={i18n.t("tickets.search.placeholder")}
|
||||
type="search"
|
||||
onChange={handleSearchContact}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.ticketsListActions}>
|
||||
<FormControlLabel
|
||||
label={i18n.t("tickets.buttons.showAll")}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showAllTickets}
|
||||
onChange={() => setShowAllTickets(prevState => !prevState)}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="add ticket"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={e => setNewTicketModalOpen(true)}
|
||||
style={{ marginLeft: 20 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Paper square elevation={0} className={classes.ticketOptionsBox}>
|
||||
{tab === "search" ? (
|
||||
<div className={classes.serachInputWrapper}>
|
||||
<SearchIcon className={classes.searchIcon} />
|
||||
<InputBase
|
||||
className={classes.searchInput}
|
||||
inputRef={searchInputRef}
|
||||
placeholder={i18n.t("tickets.search.placeholder")}
|
||||
type="search"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setNewTicketModalOpen(true)}
|
||||
>
|
||||
Novo
|
||||
</Button>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="tickets-manager:showall"
|
||||
yes={() => (
|
||||
<FormControlLabel
|
||||
label={i18n.t("tickets.buttons.showAll")}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showAllTickets}
|
||||
onChange={() =>
|
||||
setShowAllTickets(prevState => !prevState)
|
||||
}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TicketsQueueSelect
|
||||
style={{ marginLeft: 6 }}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
userQueues={user?.queues}
|
||||
onChange={values => setSelectedQueueIds(values)}
|
||||
/>
|
||||
</Paper>
|
||||
<TabPanel value={tab} name="open" className={classes.ticketsWrapper}>
|
||||
<TicketsList status="open" showAll={showAllTickets} />
|
||||
<TicketsList status="pending" showAll={true} />
|
||||
<TicketsList
|
||||
status="open"
|
||||
showAll={showAllTickets}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
/>
|
||||
<TicketsList status="pending" selectedQueueIds={selectedQueueIds} />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} name="closed" className={classes.ticketsWrapper}>
|
||||
<TicketsList status="closed" showAll={true} />
|
||||
<TicketsList
|
||||
status="closed"
|
||||
showAll={true}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} name="search" className={classes.ticketsWrapper}>
|
||||
<TicketsList searchParam={searchParam} showAll={true} />
|
||||
<TicketsList
|
||||
searchParam={searchParam}
|
||||
showAll={true}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
59
frontend/src/components/TicketsQueueSelect/index.js
Normal file
59
frontend/src/components/TicketsQueueSelect/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import { Checkbox, ListItemText } from "@material-ui/core";
|
||||
|
||||
const TicketsQueueSelect = ({
|
||||
userQueues,
|
||||
selectedQueueIds = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = e => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 120, marginTop: -4 }}>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<Select
|
||||
multiple
|
||||
displayEmpty
|
||||
variant="outlined"
|
||||
value={selectedQueueIds}
|
||||
onChange={handleChange}
|
||||
MenuProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
},
|
||||
getContentAnchorEl: null,
|
||||
}}
|
||||
renderValue={() => "Filas"}
|
||||
>
|
||||
{userQueues?.length > 0 &&
|
||||
userQueues.map(queue => (
|
||||
<MenuItem dense key={queue.id} value={queue.id}>
|
||||
<Checkbox
|
||||
style={{
|
||||
color: queue.color,
|
||||
}}
|
||||
size="small"
|
||||
color="primary"
|
||||
checked={selectedQueueIds.indexOf(queue.id) > -1}
|
||||
/>
|
||||
<ListItemText primary={queue.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsQueueSelect;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
@@ -22,15 +22,20 @@ import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
import QueueSelect from "../QueueSelect";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../Can";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
marginRight: theme.spacing(1),
|
||||
flex: 1,
|
||||
multFieldLine: {
|
||||
display: "flex",
|
||||
"& > *:not(:last-child)": {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
@@ -70,7 +75,10 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
profile: "user",
|
||||
};
|
||||
|
||||
const { user: loggedInUser } = useContext(AuthContext);
|
||||
|
||||
const [user, setUser] = useState(initialState);
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
@@ -80,6 +88,8 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
setUser(prevState => {
|
||||
return { ...prevState, ...data };
|
||||
});
|
||||
const userQueueIds = data.queues?.map(queue => queue.id);
|
||||
setSelectedQueueIds(userQueueIds);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
@@ -94,11 +104,12 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
};
|
||||
|
||||
const handleSaveUser = async values => {
|
||||
const userData = { ...values, queueIds: selectedQueueIds };
|
||||
try {
|
||||
if (userId) {
|
||||
await api.put(`/users/${userId}`, values);
|
||||
await api.put(`/users/${userId}`, userData);
|
||||
} else {
|
||||
await api.post("/users", values);
|
||||
await api.post("/users", userData);
|
||||
}
|
||||
toast.success(i18n.t("userModal.success"));
|
||||
} catch (err) {
|
||||
@@ -109,7 +120,13 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{userId
|
||||
? `${i18n.t("userModal.title.edit")}`
|
||||
@@ -129,27 +146,18 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
{({ touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<div>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.password")}
|
||||
@@ -159,28 +167,60 @@ const UserModal = ({ open, onClose, userId }) => {
|
||||
helperText={touched.password && errors.password}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl
|
||||
variant="outlined"
|
||||
className={classes.formControl}
|
||||
margin="dense"
|
||||
>
|
||||
<InputLabel id="profile-selection-input-label">
|
||||
{i18n.t("userModal.form.profile")}
|
||||
</InputLabel>
|
||||
<Field
|
||||
as={Select}
|
||||
label={i18n.t("userModal.form.profile")}
|
||||
name="profile"
|
||||
labelId="profile-selection-label"
|
||||
id="profile-selection"
|
||||
required
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
</Field>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="user-modal:editProfile"
|
||||
yes={() => (
|
||||
<>
|
||||
<InputLabel id="profile-selection-input-label">
|
||||
{i18n.t("userModal.form.profile")}
|
||||
</InputLabel>
|
||||
|
||||
<Field
|
||||
as={Select}
|
||||
label={i18n.t("userModal.form.profile")}
|
||||
name="profile"
|
||||
labelId="profile-selection-label"
|
||||
id="profile-selection"
|
||||
required
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="user-modal:editQueues"
|
||||
yes={() => (
|
||||
<QueueSelect
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
onChange={values => setSelectedQueueIds(values)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
|
||||
@@ -21,19 +21,19 @@ import {
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import toastError from "../../errors/toastError";
|
||||
import QueueSelect from "../QueueSelect";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
form: {
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifySelf: "center",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
|
||||
textField: {
|
||||
flex: 1,
|
||||
multFieldLine: {
|
||||
display: "flex",
|
||||
"& > *:not(:last-child)": {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
@@ -61,9 +61,11 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
||||
const classes = useStyles();
|
||||
const initialState = {
|
||||
name: "",
|
||||
greetingMessage: "",
|
||||
isDefault: false,
|
||||
};
|
||||
const [whatsApp, setWhatsApp] = useState(initialState);
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
@@ -72,6 +74,9 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
||||
try {
|
||||
const { data } = await api.get(`whatsapp/${whatsAppId}`);
|
||||
setWhatsApp(data);
|
||||
|
||||
const whatsQueueIds = data.queues?.map(queue => queue.id);
|
||||
setSelectedQueueIds(whatsQueueIds);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
@@ -80,20 +85,19 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
||||
}, [whatsAppId]);
|
||||
|
||||
const handleSaveWhatsApp = async values => {
|
||||
const whatsappData = { ...values, queueIds: selectedQueueIds };
|
||||
|
||||
try {
|
||||
if (whatsAppId) {
|
||||
await api.put(`/whatsapp/${whatsAppId}`, {
|
||||
name: values.name,
|
||||
isDefault: values.isDefault,
|
||||
});
|
||||
await api.put(`/whatsapp/${whatsAppId}`, whatsappData);
|
||||
} else {
|
||||
await api.post("/whatsapp", values);
|
||||
await api.post("/whatsapp", whatsappData);
|
||||
}
|
||||
toast.success(i18n.t("whatsappModal.success"));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -102,80 +106,113 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<DialogTitle>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.title.edit")
|
||||
: i18n.t("whatsappModal.title.add")}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={whatsApp}
|
||||
enableReinitialize={true}
|
||||
validationSchema={SessionSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveWhatsApp(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
{({ values, touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers className={classes.form}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("whatsappModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<DialogTitle>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.title.edit")
|
||||
: i18n.t("whatsappModal.title.add")}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={whatsApp}
|
||||
enableReinitialize={true}
|
||||
validationSchema={SessionSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveWhatsApp(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ values, touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={Switch}
|
||||
color="primary"
|
||||
name="isDefault"
|
||||
checked={values.isDefault}
|
||||
as={TextField}
|
||||
label={i18n.t("whatsappModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
}
|
||||
label={i18n.t("whatsappModal.form.default")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("whatsappModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.buttons.okEdit")
|
||||
: i18n.t("whatsappModal.buttons.okAdd")}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Field
|
||||
as={Switch}
|
||||
color="primary"
|
||||
name="isDefault"
|
||||
checked={values.isDefault}
|
||||
/>
|
||||
}
|
||||
label={i18n.t("whatsappModal.form.default")}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.greetingMessage")}
|
||||
type="greetingMessage"
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
name="greetingMessage"
|
||||
error={
|
||||
touched.greetingMessage && Boolean(errors.greetingMessage)
|
||||
}
|
||||
helperText={
|
||||
touched.greetingMessage && errors.greetingMessage
|
||||
}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
</div>
|
||||
<QueueSelect
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
onChange={values => setSelectedQueueIds(values)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("whatsappModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.buttons.okEdit")
|
||||
: i18n.t("whatsappModal.buttons.okAdd")}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import useAuth from "./useAuth";
|
||||
const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const { isAuth, loading, handleLogin, handleLogout } = useAuth();
|
||||
const { loading, user, isAuth, handleLogin, handleLogout } = useAuth();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ loading, isAuth, handleLogin, handleLogout }}
|
||||
value={{ loading, user, isAuth, handleLogin, handleLogout }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -11,6 +12,7 @@ const useAuth = () => {
|
||||
const history = useHistory();
|
||||
const [isAuth, setIsAuth] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState({});
|
||||
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
@@ -44,9 +46,6 @@ const useAuth = () => {
|
||||
}
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("profile");
|
||||
localStorage.removeItem("userId");
|
||||
api.defaults.headers.Authorization = undefined;
|
||||
setIsAuth(false);
|
||||
}
|
||||
@@ -56,49 +55,64 @@ const useAuth = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`;
|
||||
setIsAuth(true);
|
||||
}
|
||||
setLoading(false);
|
||||
(async () => {
|
||||
if (token) {
|
||||
try {
|
||||
const { data } = await api.post("/auth/refresh_token");
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
setIsAuth(true);
|
||||
setUser(data.user);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e, user) => {
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("user", data => {
|
||||
if (data.action === "update" && data.user.id === user.id) {
|
||||
setUser(data.user);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleLogin = async user => {
|
||||
setLoading(true);
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const { data } = await api.post("/auth/login", user);
|
||||
localStorage.setItem("token", JSON.stringify(data.token));
|
||||
localStorage.setItem("username", data.username);
|
||||
localStorage.setItem("profile", data.profile);
|
||||
localStorage.setItem("userId", data.userId);
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
|
||||
setUser(data.user);
|
||||
setIsAuth(true);
|
||||
toast.success(i18n.t("auth.toasts.success"));
|
||||
history.push("/tickets");
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleLogout = e => {
|
||||
const handleLogout = () => {
|
||||
setLoading(true);
|
||||
e.preventDefault();
|
||||
setIsAuth(false);
|
||||
setUser({});
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("profile");
|
||||
localStorage.removeItem("userId");
|
||||
api.defaults.headers.Authorization = undefined;
|
||||
setLoading(false);
|
||||
history.push("/login");
|
||||
};
|
||||
|
||||
return { isAuth, setIsAuth, loading, handleLogin, handleLogout };
|
||||
return { isAuth, user, loading, handleLogin, handleLogout };
|
||||
};
|
||||
|
||||
export default useAuth;
|
||||
|
||||
29
frontend/src/hooks/useLocalStorage/index.js
Normal file
29
frontend/src/hooks/useLocalStorage/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
export function useLocalStorage(key, initialValue) {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = value => {
|
||||
try {
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const useTickets = ({
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -27,6 +28,7 @@ const useTickets = ({
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
},
|
||||
});
|
||||
@@ -41,7 +43,15 @@ const useTickets = ({
|
||||
fetchTickets();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, pageNumber, status, date, showAll, withUnreadMessages]);
|
||||
}, [
|
||||
searchParam,
|
||||
pageNumber,
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
]);
|
||||
|
||||
return { tickets, loading, hasMore };
|
||||
};
|
||||
|
||||
@@ -6,18 +6,19 @@ import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import ListSubheader from "@material-ui/core/ListSubheader";
|
||||
import Divider from "@material-ui/core/Divider";
|
||||
import DashboardIcon from "@material-ui/icons/Dashboard";
|
||||
import { Badge } from "@material-ui/core";
|
||||
import DashboardOutlinedIcon from "@material-ui/icons/DashboardOutlined";
|
||||
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
|
||||
import SyncAltIcon from "@material-ui/icons/SyncAlt";
|
||||
import SettingsIcon from "@material-ui/icons/Settings";
|
||||
import GroupIcon from "@material-ui/icons/Group";
|
||||
import SettingsOutlinedIcon from "@material-ui/icons/SettingsOutlined";
|
||||
import PeopleAltOutlinedIcon from "@material-ui/icons/PeopleAltOutlined";
|
||||
import ContactPhoneOutlinedIcon from "@material-ui/icons/ContactPhoneOutlined";
|
||||
import AccountTreeOutlinedIcon from "@material-ui/icons/AccountTreeOutlined";
|
||||
|
||||
import ContactPhoneIcon from "@material-ui/icons/ContactPhone";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { Badge } from "@material-ui/core";
|
||||
|
||||
import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext";
|
||||
import { i18n } from "../translate/i18n";
|
||||
import { WhatsAppsContext } from "../context/WhatsApp/WhatsAppsContext";
|
||||
import { AuthContext } from "../context/Auth/AuthContext";
|
||||
import { Can } from "../components/Can";
|
||||
|
||||
function ListItemLink(props) {
|
||||
const { icon, primary, to, className } = props;
|
||||
@@ -41,8 +42,8 @@ function ListItemLink(props) {
|
||||
}
|
||||
|
||||
const MainListItems = () => {
|
||||
const userProfile = localStorage.getItem("profile");
|
||||
const { whatsApps } = useContext(WhatsAppsContext);
|
||||
const { user } = useContext(AuthContext);
|
||||
const [connectionWarning, setConnectionWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,7 +72,11 @@ const MainListItems = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItemLink to="/" primary="Dashboard" icon={<DashboardIcon />} />
|
||||
<ListItemLink
|
||||
to="/"
|
||||
primary="Dashboard"
|
||||
icon={<DashboardOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/connections"
|
||||
primary={i18n.t("mainDrawer.listItems.connections")}
|
||||
@@ -90,26 +95,35 @@ const MainListItems = () => {
|
||||
<ListItemLink
|
||||
to="/contacts"
|
||||
primary={i18n.t("mainDrawer.listItems.contacts")}
|
||||
icon={<ContactPhoneIcon />}
|
||||
icon={<ContactPhoneOutlinedIcon />}
|
||||
/>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="drawer-admin-items:view"
|
||||
yes={() => (
|
||||
<>
|
||||
<Divider />
|
||||
<ListSubheader inset>
|
||||
{i18n.t("mainDrawer.listItems.administration")}
|
||||
</ListSubheader>
|
||||
<ListItemLink
|
||||
to="/users"
|
||||
primary={i18n.t("mainDrawer.listItems.users")}
|
||||
icon={<PeopleAltOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/queues"
|
||||
primary={i18n.t("mainDrawer.listItems.queues")}
|
||||
icon={<AccountTreeOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/settings"
|
||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||
icon={<SettingsOutlinedIcon />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{userProfile === "admin" && (
|
||||
<>
|
||||
<Divider />
|
||||
<ListSubheader inset>
|
||||
{i18n.t("mainDrawer.listItems.administration")}
|
||||
</ListSubheader>
|
||||
<ListItemLink
|
||||
to="/users"
|
||||
primary={i18n.t("mainDrawer.listItems.users")}
|
||||
icon={<GroupIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/settings"
|
||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import React, { useState, useContext } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import {
|
||||
@@ -19,11 +19,12 @@ import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
||||
import AccountCircle from "@material-ui/icons/AccountCircle";
|
||||
|
||||
import MainListItems from "./MainListItems";
|
||||
import NotificationsPopOver from "../NotificationsPopOver";
|
||||
import UserModal from "../UserModal";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import BackdropLoading from "../BackdropLoading";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import NotificationsPopOver from "../components/NotificationsPopOver";
|
||||
import UserModal from "../components/UserModal";
|
||||
import { AuthContext } from "../context/Auth/AuthContext";
|
||||
import BackdropLoading from "../components/BackdropLoading";
|
||||
import { i18n } from "../translate/i18n";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
@@ -107,30 +108,13 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
const LoggedInLayout = ({ children }) => {
|
||||
const drawerState = localStorage.getItem("drawerOpen");
|
||||
const userId = +localStorage.getItem("userId");
|
||||
const classes = useStyles();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { handleLogout, loading } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawerState === "0") {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [drawerState]);
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
setOpen(true);
|
||||
localStorage.setItem("drawerOpen", 1);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setOpen(false);
|
||||
localStorage.setItem("drawerOpen", 0);
|
||||
};
|
||||
const [drawerOpen, setDrawerOpen] = useLocalStorage("drawerOpen", true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const handleMenu = event => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -156,12 +140,15 @@ const LoggedInLayout = ({ children }) => {
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
classes={{
|
||||
paper: clsx(classes.drawerPaper, !open && classes.drawerPaperClose),
|
||||
paper: clsx(
|
||||
classes.drawerPaper,
|
||||
!drawerOpen && classes.drawerPaperClose
|
||||
),
|
||||
}}
|
||||
open={open}
|
||||
open={drawerOpen}
|
||||
>
|
||||
<div className={classes.toolbarIcon}>
|
||||
<IconButton onClick={handleDrawerClose}>
|
||||
<IconButton onClick={() => setDrawerOpen(!drawerOpen)}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -174,11 +161,11 @@ const LoggedInLayout = ({ children }) => {
|
||||
<UserModal
|
||||
open={userModalOpen}
|
||||
onClose={() => setUserModalOpen(false)}
|
||||
userId={userId}
|
||||
userId={user?.id}
|
||||
/>
|
||||
<AppBar
|
||||
position="absolute"
|
||||
className={clsx(classes.appBar, open && classes.appBarShift)}
|
||||
className={clsx(classes.appBar, drawerOpen && classes.appBarShift)}
|
||||
color={process.env.NODE_ENV === "development" ? "inherit" : "primary"}
|
||||
>
|
||||
<Toolbar variant="dense" className={classes.toolbar}>
|
||||
@@ -186,10 +173,10 @@ const LoggedInLayout = ({ children }) => {
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||
className={clsx(
|
||||
classes.menuButton,
|
||||
open && classes.menuButtonHidden
|
||||
drawerOpen && classes.menuButtonHidden
|
||||
)}
|
||||
>
|
||||
<MenuIcon />
|
||||
@@ -294,7 +294,7 @@ const Connections = () => {
|
||||
<ConfirmationModal
|
||||
title={confirmModalInfo.title}
|
||||
open={confirmModalOpen}
|
||||
setOpen={setConfirmModalOpen}
|
||||
onClose={setConfirmModalOpen}
|
||||
onConfirm={handleSubmitConfirmationModal}
|
||||
>
|
||||
{confirmModalInfo.message}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import React, { useState, useEffect, useReducer, useContext } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
import { toast } from "react-toastify";
|
||||
import { useHistory } from "react-router-dom";
|
||||
@@ -32,6 +32,8 @@ import Title from "../../components/Title";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../../components/Can";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_CONTACTS") {
|
||||
@@ -89,7 +91,8 @@ const useStyles = makeStyles(theme => ({
|
||||
const Contacts = () => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
@@ -128,6 +131,7 @@ const Contacts = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("contact", data => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
|
||||
@@ -163,7 +167,7 @@ const Contacts = () => {
|
||||
try {
|
||||
const { data: ticket } = await api.post("/tickets", {
|
||||
contactId: contactId,
|
||||
userId: userId,
|
||||
userId: user?.id,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
@@ -228,7 +232,7 @@ const Contacts = () => {
|
||||
: `${i18n.t("contacts.confirmationModal.importTitlte")}`
|
||||
}
|
||||
open={confirmOpen}
|
||||
setOpen={setConfirmOpen}
|
||||
onClose={setConfirmOpen}
|
||||
onConfirm={e =>
|
||||
deletingContact
|
||||
? handleDeleteContact(deletingContact.id)
|
||||
@@ -315,15 +319,21 @@ const Contacts = () => {
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmOpen(true);
|
||||
setDeletingContact(contact);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="contacts-page:deleteContact"
|
||||
yes={() => (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmOpen(true);
|
||||
setDeletingContact(contact);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -61,6 +61,11 @@ const Login = () => {
|
||||
setUser({ ...user, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handlSubmit = e => {
|
||||
e.preventDefault();
|
||||
handleLogin(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
@@ -71,11 +76,7 @@ const Login = () => {
|
||||
<Typography component="h1" variant="h5">
|
||||
{i18n.t("login.title")}
|
||||
</Typography>
|
||||
<form
|
||||
className={classes.form}
|
||||
noValidate
|
||||
onSubmit={e => handleLogin(e, user)}
|
||||
>
|
||||
<form className={classes.form} noValidate onSubmit={handlSubmit}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="normal"
|
||||
|
||||
269
frontend/src/pages/Queues/index.js
Normal file
269
frontend/src/pages/Queues/index.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import Title from "../../components/Title";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import toastError from "../../errors/toastError";
|
||||
import api from "../../services/api";
|
||||
import { DeleteOutline, Edit } from "@material-ui/icons";
|
||||
import QueueModal from "../../components/QueueModal";
|
||||
import { toast } from "react-toastify";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
customTableCell: {
|
||||
display: "flex",
|
||||
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_QUEUES") {
|
||||
const queues = action.payload;
|
||||
const newQueues = [];
|
||||
|
||||
queues.forEach(queue => {
|
||||
const queueIndex = state.findIndex(q => q.id === queue.id);
|
||||
if (queueIndex !== -1) {
|
||||
state[queueIndex] = queue;
|
||||
} else {
|
||||
newQueues.push(queue);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newQueues];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_QUEUES") {
|
||||
const queue = action.payload;
|
||||
const queueIndex = state.findIndex(u => u.id === queue.id);
|
||||
|
||||
if (queueIndex !== -1) {
|
||||
state[queueIndex] = queue;
|
||||
return [...state];
|
||||
} else {
|
||||
return [queue, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_QUEUE") {
|
||||
const queueId = action.payload;
|
||||
const queueIndex = state.findIndex(q => q.id === queueId);
|
||||
if (queueIndex !== -1) {
|
||||
state.splice(queueIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const Queues = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [queues, dispatch] = useReducer(reducer, []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [queueModalOpen, setQueueModalOpen] = useState(false);
|
||||
const [selectedQueue, setSelectedQueue] = useState(null);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/queue");
|
||||
dispatch({ type: "LOAD_QUEUES", payload: data });
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("queue", data => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_QUEUES", payload: data.queue });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_QUEUE", payload: data.queueId });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenQueueModal = () => {
|
||||
setQueueModalOpen(true);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleCloseQueueModal = () => {
|
||||
setQueueModalOpen(false);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleEditQueue = queue => {
|
||||
setSelectedQueue(queue);
|
||||
setQueueModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseConfirmationModal = () => {
|
||||
setConfirmModalOpen(false);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleDeleteQueue = async queueId => {
|
||||
try {
|
||||
await api.delete(`/queue/${queueId}`);
|
||||
toast.success(i18n.t("Queue deleted successfully!"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={
|
||||
selectedQueue &&
|
||||
`${i18n.t("queues.confirmationModal.deleteTitle")} ${
|
||||
selectedQueue.name
|
||||
}?`
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
onClose={handleCloseConfirmationModal}
|
||||
onConfirm={() => handleDeleteQueue(selectedQueue.id)}
|
||||
>
|
||||
{i18n.t("queues.confirmationModal.deleteMessage")}
|
||||
</ConfirmationModal>
|
||||
<QueueModal
|
||||
open={queueModalOpen}
|
||||
onClose={handleCloseQueueModal}
|
||||
queueId={selectedQueue?.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("queues.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenQueueModal}
|
||||
>
|
||||
{i18n.t("queues.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper className={classes.mainPaper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.name")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.color")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.greeting")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<>
|
||||
{queues.map(queue => (
|
||||
<TableRow key={queue.id}>
|
||||
<TableCell align="center">{queue.name}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className={classes.customTableCell}>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: queue.color,
|
||||
width: 60,
|
||||
height: 20,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className={classes.customTableCell}>
|
||||
<Typography
|
||||
style={{ width: 300, align: "center" }}
|
||||
noWrap
|
||||
variant="body2"
|
||||
>
|
||||
{queue.greetingMessage}
|
||||
</Typography>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditQueue(queue)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedQueue(queue);
|
||||
setConfirmModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Queues;
|
||||
@@ -52,6 +52,7 @@ const Settings = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("settings", data => {
|
||||
if (data.action === "update") {
|
||||
setSettings(prevState => {
|
||||
|
||||
@@ -124,6 +124,7 @@ const Users = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("user", data => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_USERS", payload: data.user });
|
||||
@@ -192,7 +193,7 @@ const Users = () => {
|
||||
}?`
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
setOpen={setConfirmModalOpen}
|
||||
onClose={setConfirmModalOpen}
|
||||
onConfirm={() => handleDeleteUser(deletingUser.id)}
|
||||
>
|
||||
{i18n.t("users.confirmationModal.deleteMessage")}
|
||||
|
||||
@@ -7,19 +7,30 @@ import BackdropLoading from "../components/BackdropLoading";
|
||||
const RouteWrapper = ({ component: Component, isPrivate = false, ...rest }) => {
|
||||
const { isAuth, loading } = useContext(AuthContext);
|
||||
|
||||
if (loading) return <BackdropLoading />;
|
||||
|
||||
if (!isAuth && isPrivate) {
|
||||
return (
|
||||
<Redirect to={{ pathname: "/login", state: { from: rest.location } }} />
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<Redirect to={{ pathname: "/login", state: { from: rest.location } }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuth && !isPrivate) {
|
||||
return <Redirect to={{ pathname: "/", state: { from: rest.location } }} />;
|
||||
return (
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<Redirect to={{ pathname: "/", state: { from: rest.location } }} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Route {...rest} component={Component} />;
|
||||
return (
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<Route {...rest} component={Component} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteWrapper;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { BrowserRouter, Switch } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
import LoggedInLayout from "../components/_layout";
|
||||
import LoggedInLayout from "../layout";
|
||||
import Dashboard from "../pages/Dashboard/";
|
||||
import Tickets from "../pages/Tickets/";
|
||||
import Signup from "../pages/Signup/";
|
||||
@@ -11,6 +11,7 @@ import Connections from "../pages/Connections/";
|
||||
import Settings from "../pages/Settings/";
|
||||
import Users from "../pages/Users";
|
||||
import Contacts from "../pages/Contacts/";
|
||||
import Queues from "../pages/Queues/";
|
||||
import { AuthProvider } from "../context/Auth/AuthContext";
|
||||
import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext";
|
||||
import Route from "./Route";
|
||||
@@ -40,6 +41,7 @@ const Routes = () => {
|
||||
<Route exact path="/contacts" component={Contacts} isPrivate />
|
||||
<Route exact path="/users" component={Users} isPrivate />
|
||||
<Route exact path="/Settings" component={Settings} isPrivate />
|
||||
<Route exact path="/Queues" component={Queues} isPrivate />
|
||||
</LoggedInLayout>
|
||||
</WhatsAppsProvider>
|
||||
</Switch>
|
||||
|
||||
@@ -153,6 +153,22 @@ const messages = {
|
||||
},
|
||||
success: "Contact saved successfully.",
|
||||
},
|
||||
queueModal: {
|
||||
title: {
|
||||
add: "Add queue",
|
||||
edit: "Edit queue",
|
||||
},
|
||||
form: {
|
||||
name: "Name",
|
||||
color: "Color",
|
||||
greetingMessage: "Greeting Message",
|
||||
},
|
||||
buttons: {
|
||||
okAdd: "Add",
|
||||
okEdit: "Save",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
},
|
||||
userModal: {
|
||||
title: {
|
||||
add: "Add user",
|
||||
@@ -226,6 +242,7 @@ const messages = {
|
||||
connections: "Connections",
|
||||
tickets: "Tickets",
|
||||
contacts: "Contacts",
|
||||
queues: "Queues",
|
||||
administration: "Administration",
|
||||
users: "Users",
|
||||
settings: "Settings",
|
||||
@@ -240,6 +257,23 @@ const messages = {
|
||||
notifications: {
|
||||
noTickets: "No notifications.",
|
||||
},
|
||||
queues: {
|
||||
title: "Queues",
|
||||
table: {
|
||||
name: "Name",
|
||||
color: "Color",
|
||||
greeting: "Greeting message",
|
||||
actions: "Actions",
|
||||
},
|
||||
buttons: {
|
||||
add: "Add queue",
|
||||
},
|
||||
confirmationModal: {
|
||||
deleteTitle: "Delete",
|
||||
deleteMessage:
|
||||
"Are you sure? It cannot be reverted! Tickets in this queue will still exist, but will not have any queues assigned.",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
table: {
|
||||
|
||||
@@ -156,6 +156,22 @@ const messages = {
|
||||
},
|
||||
success: "Contacto guardado satisfactoriamente.",
|
||||
},
|
||||
queueModal: {
|
||||
title: {
|
||||
add: "Agregar cola",
|
||||
edit: "Editar cola",
|
||||
},
|
||||
form: {
|
||||
name: "Nombre",
|
||||
color: "Color",
|
||||
greetingMessage: "Mensaje de saludo",
|
||||
},
|
||||
buttons: {
|
||||
okAdd: "Añadir",
|
||||
okEdit: "Ahorrar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
},
|
||||
userModal: {
|
||||
title: {
|
||||
add: "Agregar usuario",
|
||||
@@ -230,6 +246,7 @@ const messages = {
|
||||
connections: "Conexiones",
|
||||
tickets: "Tickets",
|
||||
contacts: "Contactos",
|
||||
queues: "Linhas",
|
||||
administration: "Administración",
|
||||
users: "Usuarios",
|
||||
settings: "Configuración",
|
||||
@@ -244,6 +261,23 @@ const messages = {
|
||||
notifications: {
|
||||
noTickets: "Sin notificaciones.",
|
||||
},
|
||||
queues: {
|
||||
title: "Linhas",
|
||||
table: {
|
||||
name: "Nombre",
|
||||
color: "Color",
|
||||
greeting: "Mensaje de saludo",
|
||||
actions: "Comportamiento",
|
||||
},
|
||||
buttons: {
|
||||
add: "Agregar cola",
|
||||
},
|
||||
confirmationModal: {
|
||||
deleteTitle: "Eliminar",
|
||||
deleteMessage:
|
||||
"¿Estás seguro? ¡Esta acción no se puede revertir! Los tickets en esa cola seguirán existiendo, pero ya no tendrán ninguna cola asignada.",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
title: "Usuarios",
|
||||
table: {
|
||||
|
||||
@@ -154,6 +154,22 @@ const messages = {
|
||||
},
|
||||
success: "Contato salvo com sucesso.",
|
||||
},
|
||||
queueModal: {
|
||||
title: {
|
||||
add: "Adicionar fila",
|
||||
edit: "Editar fila",
|
||||
},
|
||||
form: {
|
||||
name: "Nome",
|
||||
color: "Cor",
|
||||
greetingMessage: "Mensagem de saudação",
|
||||
},
|
||||
buttons: {
|
||||
okAdd: "Adicionar",
|
||||
okEdit: "Salvar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
},
|
||||
userModal: {
|
||||
title: {
|
||||
add: "Adicionar usuário",
|
||||
@@ -228,6 +244,7 @@ const messages = {
|
||||
connections: "Conexões",
|
||||
tickets: "Tickets",
|
||||
contacts: "Contatos",
|
||||
queues: "Filas",
|
||||
administration: "Administração",
|
||||
users: "Usuários",
|
||||
settings: "Configurações",
|
||||
@@ -242,6 +259,23 @@ const messages = {
|
||||
notifications: {
|
||||
noTickets: "Nenhuma notificação.",
|
||||
},
|
||||
queues: {
|
||||
title: "Filas",
|
||||
table: {
|
||||
name: "Nome",
|
||||
color: "Cor",
|
||||
greeting: "Mensagem de saudação",
|
||||
actions: "Ações",
|
||||
},
|
||||
buttons: {
|
||||
add: "Adicionar fila",
|
||||
},
|
||||
confirmationModal: {
|
||||
deleteTitle: "Excluir",
|
||||
deleteMessage:
|
||||
"Você tem certeza? Essa ação não pode ser revertida! Os tickets dessa fila continuarão existindo, mas não terão mais nenhuma fila atribuída.",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
title: "Usuários",
|
||||
table: {
|
||||
|
||||
Reference in New Issue
Block a user