mirror of
https://github.com/cheveguerra/whaticket-community.git
synced 2026-04-20 20:59:16 +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) => {
|
app.use(async (err: Error, req: Request, res: Response, _: NextFunction) => {
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
if (err.statusCode === 403) {
|
|
||||||
logger.warn(err);
|
logger.warn(err);
|
||||||
} else {
|
|
||||||
logger.error(err);
|
|
||||||
}
|
|
||||||
return res.status(err.statusCode).json({ error: err.message });
|
return res.status(err.statusCode).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
secret: process.env.JWT_SECRET || "mysecret",
|
secret: process.env.JWT_SECRET || "mysecret",
|
||||||
expiresIn: "15m",
|
expiresIn: "15d",
|
||||||
refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret",
|
refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret",
|
||||||
refreshExpiresIn: "7d"
|
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> => {
|
export const store = async (req: Request, res: Response): Promise<Response> => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
const { token, user, refreshToken } = await AuthUserService({
|
const { token, serializedUser, refreshToken } = await AuthUserService({
|
||||||
email,
|
email,
|
||||||
password
|
password
|
||||||
});
|
});
|
||||||
@@ -17,9 +17,7 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
|||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
token,
|
token,
|
||||||
username: user.name,
|
user: serializedUser
|
||||||
profile: user.profile,
|
|
||||||
userId: user.id
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,9 +31,9 @@ export const update = async (
|
|||||||
throw new AppError("ERR_SESSION_EXPIRED", 401);
|
throw new AppError("ERR_SESSION_EXPIRED", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newToken, refreshToken } = await RefreshTokenService(token);
|
const { user, newToken, refreshToken } = await RefreshTokenService(token);
|
||||||
|
|
||||||
SendRefreshToken(res, refreshToken);
|
SendRefreshToken(res, refreshToken);
|
||||||
|
|
||||||
return res.json({ token: newToken });
|
return res.json({ token: newToken, user });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type IndexQuery = {
|
|||||||
date: string;
|
date: string;
|
||||||
showAll: string;
|
showAll: string;
|
||||||
withUnreadMessages: string;
|
withUnreadMessages: string;
|
||||||
|
queueIds: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
@@ -29,11 +30,18 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
|||||||
date,
|
date,
|
||||||
searchParam,
|
searchParam,
|
||||||
showAll,
|
showAll,
|
||||||
|
queueIds: queueIdsStringified,
|
||||||
withUnreadMessages
|
withUnreadMessages
|
||||||
} = req.query as IndexQuery;
|
} = req.query as IndexQuery;
|
||||||
|
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
let queueIds: number[] = [];
|
||||||
|
|
||||||
|
if (queueIdsStringified) {
|
||||||
|
queueIds = JSON.parse(queueIdsStringified);
|
||||||
|
}
|
||||||
|
|
||||||
const { tickets, count, hasMore } = await ListTicketsService({
|
const { tickets, count, hasMore } = await ListTicketsService({
|
||||||
searchParam,
|
searchParam,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
@@ -41,6 +49,7 @@ export const index = async (req: Request, res: Response): Promise<Response> => {
|
|||||||
date,
|
date,
|
||||||
showAll,
|
showAll,
|
||||||
userId,
|
userId,
|
||||||
|
queueIds,
|
||||||
withUnreadMessages
|
withUnreadMessages
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +63,7 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
|||||||
|
|
||||||
const io = getIO();
|
const io = getIO();
|
||||||
io.to(ticket.status).emit("ticket", {
|
io.to(ticket.status).emit("ticket", {
|
||||||
action: "create",
|
action: "update",
|
||||||
ticket
|
ticket
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +100,7 @@ export const update = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.to(ticket.status).to("notification").to(ticketId).emit("ticket", {
|
io.to(ticket.status).to("notification").to(ticketId).emit("ticket", {
|
||||||
action: "updateStatus",
|
action: "update",
|
||||||
ticket
|
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> => {
|
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 (
|
if (
|
||||||
req.url === "/signup" &&
|
req.url === "/signup" &&
|
||||||
@@ -42,7 +42,8 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
name,
|
name,
|
||||||
profile
|
profile,
|
||||||
|
queueIds
|
||||||
});
|
});
|
||||||
|
|
||||||
const io = getIO();
|
const io = getIO();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import UpdateWhatsAppService from "../services/WhatsappService/UpdateWhatsAppSer
|
|||||||
|
|
||||||
interface WhatsappData {
|
interface WhatsappData {
|
||||||
name: string;
|
name: string;
|
||||||
|
queueIds: number[];
|
||||||
|
greetingMessage?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
isDefault?: boolean;
|
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> => {
|
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({
|
const { whatsapp, oldDefaultWhatsapp } = await CreateWhatsAppService({
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
isDefault
|
isDefault,
|
||||||
|
greetingMessage,
|
||||||
|
queueIds
|
||||||
});
|
});
|
||||||
|
|
||||||
StartWhatsAppSession(whatsapp);
|
// StartWhatsAppSession(whatsapp);
|
||||||
|
|
||||||
const io = getIO();
|
const io = getIO();
|
||||||
io.emit("whatsapp", {
|
io.emit("whatsapp", {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import Ticket from "../models/Ticket";
|
|||||||
import Whatsapp from "../models/Whatsapp";
|
import Whatsapp from "../models/Whatsapp";
|
||||||
import ContactCustomField from "../models/ContactCustomField";
|
import ContactCustomField from "../models/ContactCustomField";
|
||||||
import Message from "../models/Message";
|
import Message from "../models/Message";
|
||||||
|
import Queue from "../models/Queue";
|
||||||
|
import WhatsappQueue from "../models/WhatsappQueue";
|
||||||
|
import UserQueue from "../models/UserQueue";
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const dbConfig = require("../config/database");
|
const dbConfig = require("../config/database");
|
||||||
@@ -20,7 +23,10 @@ const models = [
|
|||||||
Message,
|
Message,
|
||||||
Whatsapp,
|
Whatsapp,
|
||||||
ContactCustomField,
|
ContactCustomField,
|
||||||
Setting
|
Setting,
|
||||||
|
Queue,
|
||||||
|
WhatsappQueue,
|
||||||
|
UserQueue
|
||||||
];
|
];
|
||||||
|
|
||||||
sequelize.addModels(models);
|
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;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new AppError("Token was not provided.", 403);
|
throw new AppError("ERR_SESSION_EXPIRED", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, token] = authHeader.split(" ");
|
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 Contact from "./Contact";
|
||||||
import Message from "./Message";
|
import Message from "./Message";
|
||||||
|
import Queue from "./Queue";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import Whatsapp from "./Whatsapp";
|
import Whatsapp from "./Whatsapp";
|
||||||
|
|
||||||
@@ -64,6 +65,13 @@ class Ticket extends Model<Ticket> {
|
|||||||
@BelongsTo(() => Whatsapp)
|
@BelongsTo(() => Whatsapp)
|
||||||
whatsapp: Whatsapp;
|
whatsapp: Whatsapp;
|
||||||
|
|
||||||
|
@ForeignKey(() => Queue)
|
||||||
|
@Column
|
||||||
|
queueId: number;
|
||||||
|
|
||||||
|
@BelongsTo(() => Queue)
|
||||||
|
queue: Queue;
|
||||||
|
|
||||||
@HasMany(() => Message)
|
@HasMany(() => Message)
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
AutoIncrement,
|
AutoIncrement,
|
||||||
Default,
|
Default,
|
||||||
HasMany
|
HasMany,
|
||||||
|
BelongsToMany
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import { hash, compare } from "bcryptjs";
|
import { hash, compare } from "bcryptjs";
|
||||||
import Ticket from "./Ticket";
|
import Ticket from "./Ticket";
|
||||||
|
import Queue from "./Queue";
|
||||||
|
import UserQueue from "./UserQueue";
|
||||||
|
|
||||||
@Table
|
@Table
|
||||||
class User extends Model<User> {
|
class User extends Model<User> {
|
||||||
@@ -51,6 +54,9 @@ class User extends Model<User> {
|
|||||||
@HasMany(() => Ticket)
|
@HasMany(() => Ticket)
|
||||||
tickets: Ticket[];
|
tickets: Ticket[];
|
||||||
|
|
||||||
|
@BelongsToMany(() => Queue, () => UserQueue)
|
||||||
|
queues: Queue[];
|
||||||
|
|
||||||
@BeforeUpdate
|
@BeforeUpdate
|
||||||
@BeforeCreate
|
@BeforeCreate
|
||||||
static hashPassword = async (instance: User): Promise<void> => {
|
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,
|
Default,
|
||||||
AllowNull,
|
AllowNull,
|
||||||
HasMany,
|
HasMany,
|
||||||
Unique
|
Unique,
|
||||||
|
BelongsToMany
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
|
import Queue from "./Queue";
|
||||||
import Ticket from "./Ticket";
|
import Ticket from "./Ticket";
|
||||||
|
import WhatsappQueue from "./WhatsappQueue";
|
||||||
|
|
||||||
@Table
|
@Table
|
||||||
class Whatsapp extends Model<Whatsapp> {
|
class Whatsapp extends Model<Whatsapp> {
|
||||||
@@ -44,6 +47,9 @@ class Whatsapp extends Model<Whatsapp> {
|
|||||||
@Column
|
@Column
|
||||||
retries: number;
|
retries: number;
|
||||||
|
|
||||||
|
@Column(DataType.TEXT)
|
||||||
|
greetingMessage: string;
|
||||||
|
|
||||||
@Default(false)
|
@Default(false)
|
||||||
@AllowNull
|
@AllowNull
|
||||||
@Column
|
@Column
|
||||||
@@ -57,6 +63,12 @@ class Whatsapp extends Model<Whatsapp> {
|
|||||||
|
|
||||||
@HasMany(() => Ticket)
|
@HasMany(() => Ticket)
|
||||||
tickets: Ticket[];
|
tickets: Ticket[];
|
||||||
|
|
||||||
|
@BelongsToMany(() => Queue, () => WhatsappQueue)
|
||||||
|
queues: Array<Queue & { WhatsappQueue: WhatsappQueue }>;
|
||||||
|
|
||||||
|
@HasMany(() => WhatsappQueue)
|
||||||
|
whatsappQueues: WhatsappQueue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Whatsapp;
|
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 whatsappRoutes from "./whatsappRoutes";
|
||||||
import messageRoutes from "./messageRoutes";
|
import messageRoutes from "./messageRoutes";
|
||||||
import whatsappSessionRoutes from "./whatsappSessionRoutes";
|
import whatsappSessionRoutes from "./whatsappSessionRoutes";
|
||||||
|
import queueRoutes from "./queueRoutes";
|
||||||
|
|
||||||
const routes = Router();
|
const routes = Router();
|
||||||
|
|
||||||
@@ -20,5 +21,6 @@ routes.use(whatsappRoutes);
|
|||||||
routes.use(messageRoutes);
|
routes.use(messageRoutes);
|
||||||
routes.use(messageRoutes);
|
routes.use(messageRoutes);
|
||||||
routes.use(whatsappSessionRoutes);
|
routes.use(whatsappSessionRoutes);
|
||||||
|
routes.use(queueRoutes);
|
||||||
|
|
||||||
export default routes;
|
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 { verify } from "jsonwebtoken";
|
||||||
|
|
||||||
|
import User from "../../models/User";
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
import ShowUserService from "../UserServices/ShowUserService";
|
import ShowUserService from "../UserServices/ShowUserService";
|
||||||
import authConfig from "../../config/auth";
|
import authConfig from "../../config/auth";
|
||||||
@@ -13,6 +15,7 @@ interface RefreshTokenPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Response {
|
interface Response {
|
||||||
|
user: User;
|
||||||
newToken: string;
|
newToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
@@ -37,5 +40,5 @@ export const RefreshTokenService = async (token: string): Promise<Response> => {
|
|||||||
const newToken = createAccessToken(user);
|
const newToken = createAccessToken(user);
|
||||||
const refreshToken = createRefreshToken(user);
|
const refreshToken = createRefreshToken(user);
|
||||||
|
|
||||||
return { newToken, refreshToken };
|
return { user, newToken, refreshToken };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const CreateMessageService = async ({
|
|||||||
{
|
{
|
||||||
model: Ticket,
|
model: Ticket,
|
||||||
as: "ticket",
|
as: "ticket",
|
||||||
include: ["contact"]
|
include: ["contact", "queue"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Message,
|
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"]
|
[Op.or]: ["open", "pending"]
|
||||||
},
|
},
|
||||||
contactId: groupContact ? groupContact.id : contact.id
|
contactId: groupContact ? groupContact.id : contact.id
|
||||||
},
|
}
|
||||||
include: ["contact"]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
await ticket.update({ unreadMessages });
|
await ticket.update({ unreadMessages });
|
||||||
|
|
||||||
return ticket;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupContact) {
|
if (!ticket && groupContact) {
|
||||||
ticket = await Ticket.findOne({
|
ticket = await Ticket.findOne({
|
||||||
where: {
|
where: {
|
||||||
contactId: groupContact.id
|
contactId: groupContact.id
|
||||||
},
|
},
|
||||||
order: [["updatedAt", "DESC"]],
|
order: [["updatedAt", "DESC"]]
|
||||||
include: ["contact"]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
@@ -41,10 +37,10 @@ const FindOrCreateTicketService = async (
|
|||||||
userId: null,
|
userId: null,
|
||||||
unreadMessages
|
unreadMessages
|
||||||
});
|
});
|
||||||
|
|
||||||
return ticket;
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (!ticket && !groupContact) {
|
||||||
ticket = await Ticket.findOne({
|
ticket = await Ticket.findOne({
|
||||||
where: {
|
where: {
|
||||||
updatedAt: {
|
updatedAt: {
|
||||||
@@ -52,8 +48,7 @@ const FindOrCreateTicketService = async (
|
|||||||
},
|
},
|
||||||
contactId: contact.id
|
contactId: contact.id
|
||||||
},
|
},
|
||||||
order: [["updatedAt", "DESC"]],
|
order: [["updatedAt", "DESC"]]
|
||||||
include: ["contact"]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
@@ -62,20 +57,20 @@ const FindOrCreateTicketService = async (
|
|||||||
userId: null,
|
userId: null,
|
||||||
unreadMessages
|
unreadMessages
|
||||||
});
|
});
|
||||||
|
|
||||||
return ticket;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await Ticket.create({
|
if (!ticket) {
|
||||||
|
ticket = await Ticket.create({
|
||||||
contactId: groupContact ? groupContact.id : contact.id,
|
contactId: groupContact ? groupContact.id : contact.id,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
isGroup: !!groupContact,
|
isGroup: !!groupContact,
|
||||||
unreadMessages,
|
unreadMessages,
|
||||||
whatsappId
|
whatsappId
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ticket = await ShowTicketService(id);
|
ticket = await ShowTicketService(ticket.id);
|
||||||
|
|
||||||
return ticket;
|
return ticket;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { startOfDay, endOfDay, parseISO } from "date-fns";
|
|||||||
import Ticket from "../../models/Ticket";
|
import Ticket from "../../models/Ticket";
|
||||||
import Contact from "../../models/Contact";
|
import Contact from "../../models/Contact";
|
||||||
import Message from "../../models/Message";
|
import Message from "../../models/Message";
|
||||||
|
import Queue from "../../models/Queue";
|
||||||
|
import ShowUserService from "../UserServices/ShowUserService";
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
searchParam?: string;
|
searchParam?: string;
|
||||||
@@ -13,6 +15,7 @@ interface Request {
|
|||||||
showAll?: string;
|
showAll?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withUnreadMessages?: string;
|
withUnreadMessages?: string;
|
||||||
|
queueIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Response {
|
interface Response {
|
||||||
@@ -24,6 +27,7 @@ interface Response {
|
|||||||
const ListTicketsService = async ({
|
const ListTicketsService = async ({
|
||||||
searchParam = "",
|
searchParam = "",
|
||||||
pageNumber = "1",
|
pageNumber = "1",
|
||||||
|
queueIds,
|
||||||
status,
|
status,
|
||||||
date,
|
date,
|
||||||
showAll,
|
showAll,
|
||||||
@@ -31,7 +35,8 @@ const ListTicketsService = async ({
|
|||||||
withUnreadMessages
|
withUnreadMessages
|
||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
let whereCondition: Filterable["where"] = {
|
let whereCondition: Filterable["where"] = {
|
||||||
[Op.or]: [{ userId }, { status: "pending" }]
|
[Op.or]: [{ userId }, { status: "pending" }],
|
||||||
|
queueId: { [Op.or]: [queueIds, null] }
|
||||||
};
|
};
|
||||||
let includeCondition: Includeable[];
|
let includeCondition: Includeable[];
|
||||||
|
|
||||||
@@ -40,11 +45,16 @@ const ListTicketsService = async ({
|
|||||||
model: Contact,
|
model: Contact,
|
||||||
as: "contact",
|
as: "contact",
|
||||||
attributes: ["id", "name", "number", "profilePicUrl"]
|
attributes: ["id", "name", "number", "profilePicUrl"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Queue,
|
||||||
|
as: "queue",
|
||||||
|
attributes: ["id", "name", "color"]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (showAll === "true") {
|
if (showAll === "true") {
|
||||||
whereCondition = {};
|
whereCondition = { queueId: { [Op.or]: [queueIds, null] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -76,10 +86,11 @@ const ListTicketsService = async ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
whereCondition = {
|
whereCondition = {
|
||||||
|
...whereCondition,
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{
|
{
|
||||||
"$contact.name$": where(
|
"$contact.name$": where(
|
||||||
fn("LOWER", col("name")),
|
fn("LOWER", col("contact.name")),
|
||||||
"LIKE",
|
"LIKE",
|
||||||
`%${sanitizedSearchParam}%`
|
`%${sanitizedSearchParam}%`
|
||||||
)
|
)
|
||||||
@@ -106,18 +117,14 @@ const ListTicketsService = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (withUnreadMessages === "true") {
|
if (withUnreadMessages === "true") {
|
||||||
includeCondition = [
|
const user = await ShowUserService(userId);
|
||||||
...includeCondition,
|
const userQueueIds = user.queues.map(queue => queue.id);
|
||||||
{
|
|
||||||
model: Message,
|
whereCondition = {
|
||||||
as: "messages",
|
[Op.or]: [{ userId }, { status: "pending" }],
|
||||||
attributes: [],
|
queueId: { [Op.or]: [userQueueIds, null] },
|
||||||
where: {
|
unreadMessages: { [Op.gt]: 0 }
|
||||||
read: false,
|
};
|
||||||
fromMe: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Ticket from "../../models/Ticket";
|
|||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
import Contact from "../../models/Contact";
|
import Contact from "../../models/Contact";
|
||||||
import User from "../../models/User";
|
import User from "../../models/User";
|
||||||
|
import Queue from "../../models/Queue";
|
||||||
|
|
||||||
const ShowTicketService = async (id: string | number): Promise<Ticket> => {
|
const ShowTicketService = async (id: string | number): Promise<Ticket> => {
|
||||||
const ticket = await Ticket.findByPk(id, {
|
const ticket = await Ticket.findByPk(id, {
|
||||||
@@ -16,6 +17,11 @@ const ShowTicketService = async (id: string | number): Promise<Ticket> => {
|
|||||||
model: User,
|
model: User,
|
||||||
as: "user",
|
as: "user",
|
||||||
attributes: ["id", "name"]
|
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 CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets";
|
||||||
import SetTicketMessagesAsRead from "../../helpers/SetTicketMessagesAsRead";
|
import SetTicketMessagesAsRead from "../../helpers/SetTicketMessagesAsRead";
|
||||||
import Contact from "../../models/Contact";
|
|
||||||
import Ticket from "../../models/Ticket";
|
import Ticket from "../../models/Ticket";
|
||||||
import User from "../../models/User";
|
import ShowTicketService from "./ShowTicketService";
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -27,25 +25,7 @@ const UpdateTicketService = async ({
|
|||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
const { status, userId } = ticketData;
|
const { status, userId } = ticketData;
|
||||||
|
|
||||||
const ticket = await Ticket.findOne({
|
const ticket = await ShowTicketService(ticketId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await SetTicketMessagesAsRead(ticket);
|
await SetTicketMessagesAsRead(ticket);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import {
|
|||||||
createAccessToken,
|
createAccessToken,
|
||||||
createRefreshToken
|
createRefreshToken
|
||||||
} from "../../helpers/CreateTokens";
|
} 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 {
|
interface Request {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -11,7 +21,7 @@ interface Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Response {
|
interface Response {
|
||||||
user: User;
|
serializedUser: SerializedUser;
|
||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
@@ -21,7 +31,8 @@ const AuthUserService = async ({
|
|||||||
password
|
password
|
||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
where: { email }
|
where: { email },
|
||||||
|
include: ["queues"]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -35,8 +46,10 @@ const AuthUserService = async ({
|
|||||||
const token = createAccessToken(user);
|
const token = createAccessToken(user);
|
||||||
const refreshToken = createRefreshToken(user);
|
const refreshToken = createRefreshToken(user);
|
||||||
|
|
||||||
|
const serializedUser = SerializeUser(user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
serializedUser,
|
||||||
token,
|
token,
|
||||||
refreshToken
|
refreshToken
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
|
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
|
import { SerializeUser } from "../../helpers/SerializeUser";
|
||||||
import User from "../../models/User";
|
import User from "../../models/User";
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
queueIds?: number[];
|
||||||
profile?: string;
|
profile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ const CreateUserService = async ({
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
name,
|
name,
|
||||||
|
queueIds = [],
|
||||||
profile = "admin"
|
profile = "admin"
|
||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
const schema = Yup.object().shape({
|
const schema = Yup.object().shape({
|
||||||
@@ -47,19 +50,21 @@ const CreateUserService = async ({
|
|||||||
throw new AppError(err.message);
|
throw new AppError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.create({
|
const user = await User.create(
|
||||||
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
name,
|
name,
|
||||||
profile
|
profile
|
||||||
});
|
},
|
||||||
|
{ include: ["queues"] }
|
||||||
|
);
|
||||||
|
|
||||||
const serializedUser = {
|
await user.$set("queues", queueIds);
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
await user.reload();
|
||||||
email: user.email,
|
|
||||||
profile: user.profile
|
const serializedUser = SerializeUser(user);
|
||||||
};
|
|
||||||
|
|
||||||
return serializedUser;
|
return serializedUser;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Sequelize, Op } from "sequelize";
|
import { Sequelize, Op } from "sequelize";
|
||||||
|
import Queue from "../../models/Queue";
|
||||||
import User from "../../models/User";
|
import User from "../../models/User";
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
@@ -19,8 +20,8 @@ const ListUsersService = async ({
|
|||||||
const whereCondition = {
|
const whereCondition = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{
|
{
|
||||||
name: Sequelize.where(
|
"$User.name$": Sequelize.where(
|
||||||
Sequelize.fn("LOWER", Sequelize.col("name")),
|
Sequelize.fn("LOWER", Sequelize.col("User.name")),
|
||||||
"LIKE",
|
"LIKE",
|
||||||
`%${searchParam.toLowerCase()}%`
|
`%${searchParam.toLowerCase()}%`
|
||||||
)
|
)
|
||||||
@@ -33,10 +34,13 @@ const ListUsersService = async ({
|
|||||||
|
|
||||||
const { count, rows: users } = await User.findAndCountAll({
|
const { count, rows: users } = await User.findAndCountAll({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
attributes: ["name", "id", "email", "profile"],
|
attributes: ["name", "id", "email", "profile", "createdAt"],
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
order: [["createdAt", "DESC"]]
|
order: [["createdAt", "DESC"]],
|
||||||
|
include: [
|
||||||
|
{ model: Queue, as: "queues", attributes: ["id", "name", "color"] }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasMore = count > offset + users.length;
|
const hasMore = count > offset + users.length;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import User from "../../models/User";
|
import User from "../../models/User";
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
|
import Queue from "../../models/Queue";
|
||||||
|
|
||||||
const ShowUserService = async (id: string | number): Promise<User> => {
|
const ShowUserService = async (id: string | number): Promise<User> => {
|
||||||
const user = await User.findByPk(id, {
|
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) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
|
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
import User from "../../models/User";
|
import ShowUserService from "./ShowUserService";
|
||||||
|
|
||||||
interface UserData {
|
interface UserData {
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
|
queueIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
@@ -26,14 +27,7 @@ const UpdateUserService = async ({
|
|||||||
userData,
|
userData,
|
||||||
userId
|
userId
|
||||||
}: Request): Promise<Response | undefined> => {
|
}: Request): Promise<Response | undefined> => {
|
||||||
const user = await User.findOne({
|
const user = await ShowUserService(userId);
|
||||||
where: { id: userId },
|
|
||||||
attributes: ["name", "id", "email", "profile"]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new AppError("ERR_NO_USER_FOUND", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = Yup.object().shape({
|
const schema = Yup.object().shape({
|
||||||
name: Yup.string().min(2),
|
name: Yup.string().min(2),
|
||||||
@@ -42,7 +36,7 @@ const UpdateUserService = async ({
|
|||||||
password: Yup.string()
|
password: Yup.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { email, password, profile, name } = userData;
|
const { email, password, profile, name, queueIds = [] } = userData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await schema.validate({ email, password, profile, name });
|
await schema.validate({ email, password, profile, name });
|
||||||
@@ -57,11 +51,16 @@ const UpdateUserService = async ({
|
|||||||
name
|
name
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await user.$set("queues", queueIds);
|
||||||
|
|
||||||
|
await user.reload();
|
||||||
|
|
||||||
const serializedUser = {
|
const serializedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
profile: user.profile
|
profile: user.profile,
|
||||||
|
queues: user.queues
|
||||||
};
|
};
|
||||||
|
|
||||||
return serializedUser;
|
return serializedUser;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Whatsapp from "../../models/Whatsapp";
|
import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService";
|
||||||
import { StartWhatsAppSession } from "./StartWhatsAppSession";
|
import { StartWhatsAppSession } from "./StartWhatsAppSession";
|
||||||
|
|
||||||
export const StartAllWhatsAppsSessions = async (): Promise<void> => {
|
export const StartAllWhatsAppsSessions = async (): Promise<void> => {
|
||||||
const whatsapps = await Whatsapp.findAll();
|
const whatsapps = await ListWhatsAppsService();
|
||||||
if (whatsapps.length > 0) {
|
if (whatsapps.length > 0) {
|
||||||
whatsapps.forEach(whatsapp => {
|
whatsapps.forEach(whatsapp => {
|
||||||
StartWhatsAppSession(whatsapp);
|
StartWhatsAppSession(whatsapp);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import CreateMessageService from "../MessageServices/CreateMessageService";
|
|||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import CreateOrUpdateContactService from "../ContactServices/CreateOrUpdateContactService";
|
import CreateOrUpdateContactService from "../ContactServices/CreateOrUpdateContactService";
|
||||||
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";
|
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";
|
||||||
|
import ShowWhatsAppService from "../WhatsappService/ShowWhatsAppService";
|
||||||
|
import { debounce } from "../../helpers/Debounce";
|
||||||
|
|
||||||
interface Session extends Client {
|
interface Session extends Client {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -126,6 +128,60 @@ const verifyMessage = async (
|
|||||||
await CreateMessageService({ messageData });
|
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 => {
|
const isValidMsg = (msg: WbotMessage): boolean => {
|
||||||
if (msg.from === "status@broadcast") return false;
|
if (msg.from === "status@broadcast") return false;
|
||||||
if (
|
if (
|
||||||
@@ -146,8 +202,6 @@ const handleMessage = async (
|
|||||||
msg: WbotMessage,
|
msg: WbotMessage,
|
||||||
wbot: Session
|
wbot: Session
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
(async () => {
|
|
||||||
if (!isValidMsg(msg)) {
|
if (!isValidMsg(msg)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,11 +211,14 @@ const handleMessage = async (
|
|||||||
let groupContact: Contact | undefined;
|
let groupContact: Contact | undefined;
|
||||||
|
|
||||||
if (msg.fromMe) {
|
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"
|
// 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"
|
// 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")
|
if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard") return;
|
||||||
return;
|
|
||||||
|
|
||||||
msgContact = await wbot.getContactById(msg.to);
|
msgContact = await wbot.getContactById(msg.to);
|
||||||
} else {
|
} else {
|
||||||
@@ -194,18 +251,17 @@ const handleMessage = async (
|
|||||||
|
|
||||||
if (msg.hasMedia) {
|
if (msg.hasMedia) {
|
||||||
await verifyMediaMessage(msg, ticket, contact);
|
await verifyMediaMessage(msg, ticket, contact);
|
||||||
resolve();
|
|
||||||
} else {
|
} else {
|
||||||
await verifyMessage(msg, ticket, contact);
|
await verifyMessage(msg, ticket, contact);
|
||||||
resolve();
|
}
|
||||||
|
|
||||||
|
if (!ticket.queue && !chat.isGroup && !msg.fromMe && !ticket.userId) {
|
||||||
|
await verifyQueue(wbot, msg, ticket, contact);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Sentry.captureException(err);
|
Sentry.captureException(err);
|
||||||
logger.error(`Error handling whatsapp message: Err: ${err}`);
|
logger.error(`Error handling whatsapp message: Err: ${err}`);
|
||||||
reject(err);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMsgAck = async (msg: WbotMessage, ack: MessageAck) => {
|
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 AppError from "../../errors/AppError";
|
||||||
import Whatsapp from "../../models/Whatsapp";
|
import Whatsapp from "../../models/Whatsapp";
|
||||||
|
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
name: string;
|
name: string;
|
||||||
|
queueIds?: number[];
|
||||||
|
greetingMessage?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
@@ -17,6 +20,8 @@ interface Response {
|
|||||||
const CreateWhatsAppService = async ({
|
const CreateWhatsAppService = async ({
|
||||||
name,
|
name,
|
||||||
status = "OPENING",
|
status = "OPENING",
|
||||||
|
queueIds = [],
|
||||||
|
greetingMessage,
|
||||||
isDefault = false
|
isDefault = false
|
||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
const schema = Yup.object().shape({
|
const schema = Yup.object().shape({
|
||||||
@@ -62,11 +67,21 @@ const CreateWhatsAppService = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whatsapp = await Whatsapp.create({
|
if (queueIds.length > 1 && !greetingMessage) {
|
||||||
|
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const whatsapp = await Whatsapp.create(
|
||||||
|
{
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
|
greetingMessage,
|
||||||
isDefault
|
isDefault
|
||||||
});
|
},
|
||||||
|
{ include: ["queues"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
await AssociateWhatsappQueue(whatsapp, queueIds);
|
||||||
|
|
||||||
return { whatsapp, oldDefaultWhatsapp };
|
return { whatsapp, oldDefaultWhatsapp };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import Queue from "../../models/Queue";
|
||||||
import Whatsapp from "../../models/Whatsapp";
|
import Whatsapp from "../../models/Whatsapp";
|
||||||
|
|
||||||
const ListWhatsAppsService = async (): Promise<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;
|
return whatsapps;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import Whatsapp from "../../models/Whatsapp";
|
import Whatsapp from "../../models/Whatsapp";
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
|
import Queue from "../../models/Queue";
|
||||||
|
|
||||||
const ShowWhatsAppService = async (id: string | number): Promise<Whatsapp> => {
|
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) {
|
if (!whatsapp) {
|
||||||
throw new AppError("ERR_NO_WAPP_FOUND", 404);
|
throw new AppError("ERR_NO_WAPP_FOUND", 404);
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { Op } from "sequelize";
|
|||||||
|
|
||||||
import AppError from "../../errors/AppError";
|
import AppError from "../../errors/AppError";
|
||||||
import Whatsapp from "../../models/Whatsapp";
|
import Whatsapp from "../../models/Whatsapp";
|
||||||
|
import ShowWhatsAppService from "./ShowWhatsAppService";
|
||||||
|
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
|
||||||
|
|
||||||
interface WhatsappData {
|
interface WhatsappData {
|
||||||
name?: string;
|
name?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
session?: string;
|
session?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
greetingMessage?: string;
|
||||||
|
queueIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
@@ -26,11 +30,18 @@ const UpdateWhatsAppService = async ({
|
|||||||
whatsappId
|
whatsappId
|
||||||
}: Request): Promise<Response> => {
|
}: Request): Promise<Response> => {
|
||||||
const schema = Yup.object().shape({
|
const schema = Yup.object().shape({
|
||||||
name: Yup.string().min(2),
|
name: Yup.string().min(2).required(),
|
||||||
isDefault: Yup.boolean()
|
isDefault: Yup.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { name, status, isDefault, session } = whatsappData;
|
const {
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
isDefault,
|
||||||
|
session,
|
||||||
|
greetingMessage,
|
||||||
|
queueIds = []
|
||||||
|
} = whatsappData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await schema.validate({ name, status, isDefault });
|
await schema.validate({ name, status, isDefault });
|
||||||
@@ -38,6 +49,10 @@ const UpdateWhatsAppService = async ({
|
|||||||
throw new AppError(err.message);
|
throw new AppError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (queueIds.length > 1 && !greetingMessage) {
|
||||||
|
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
let oldDefaultWhatsapp: Whatsapp | null = null;
|
let oldDefaultWhatsapp: Whatsapp | null = null;
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -49,20 +64,18 @@ const UpdateWhatsAppService = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whatsapp = await Whatsapp.findOne({
|
const whatsapp = await ShowWhatsAppService(whatsappId);
|
||||||
where: { id: whatsappId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!whatsapp) {
|
|
||||||
throw new AppError("ERR_NO_WAPP_FOUND", 404);
|
|
||||||
}
|
|
||||||
await whatsapp.update({
|
await whatsapp.update({
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
session,
|
session,
|
||||||
|
greetingMessage,
|
||||||
isDefault
|
isDefault
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await AssociateWhatsappQueue(whatsapp, queueIds);
|
||||||
|
|
||||||
return { whatsapp, oldDefaultWhatsapp };
|
return { whatsapp, oldDefaultWhatsapp };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"mic-recorder-to-mp3": "^2.2.2",
|
"mic-recorder-to-mp3": "^2.2.2",
|
||||||
"qrcode.react": "^1.0.0",
|
"qrcode.react": "^1.0.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-modal-image": "^2.5.0",
|
"react-modal-image": "^2.5.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>WhaTicket</title>
|
<title>WhaTicket</title>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
|
||||||
rel="stylesheet"
|
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="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<link rel=”shortcut icon” href=”%PUBLIC_URL%/favicon.ico”>
|
<link rel=”shortcut icon” href=”%PUBLIC_URL%/favicon.ico”>
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="minimum-scale=1, initial-scale=1, width=device-width"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<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";
|
import { i18n } from "../../translate/i18n";
|
||||||
|
|
||||||
const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => onClose(false)}
|
||||||
aria-labelledby="confirm-dialog"
|
aria-labelledby="confirm-dialog"
|
||||||
>
|
>
|
||||||
<DialogTitle id="confirm-dialog">{title}</DialogTitle>
|
<DialogTitle id="confirm-dialog">{title}</DialogTitle>
|
||||||
@@ -22,7 +22,7 @@ const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
|||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => onClose(false)}
|
||||||
color="default"
|
color="default"
|
||||||
>
|
>
|
||||||
{i18n.t("confirmationModal.buttons.cancel")}
|
{i18n.t("confirmationModal.buttons.cancel")}
|
||||||
@@ -30,7 +30,7 @@ const ConfirmationModal = ({ title, children, open, setOpen, onConfirm }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
onClose(false);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}}
|
}}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { i18n } from "../../translate/i18n";
|
|||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import RecordingTimer from "./RecordingTimer";
|
import RecordingTimer from "./RecordingTimer";
|
||||||
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
|
||||||
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
|
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
|
||||||
@@ -164,7 +166,6 @@ const useStyles = makeStyles(theme => ({
|
|||||||
const MessageInput = ({ ticketStatus }) => {
|
const MessageInput = ({ ticketStatus }) => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const { ticketId } = useParams();
|
const { ticketId } = useParams();
|
||||||
const username = localStorage.getItem("username");
|
|
||||||
|
|
||||||
const [medias, setMedias] = useState([]);
|
const [medias, setMedias] = useState([]);
|
||||||
const [inputMessage, setInputMessage] = useState("");
|
const [inputMessage, setInputMessage] = useState("");
|
||||||
@@ -175,17 +176,9 @@ const MessageInput = ({ ticketStatus }) => {
|
|||||||
const { setReplyingMessage, replyingMessage } = useContext(
|
const { setReplyingMessage, replyingMessage } = useContext(
|
||||||
ReplyMessageContext
|
ReplyMessageContext
|
||||||
);
|
);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
const [signMessage, setSignMessage] = useState(false);
|
const [signMessage, setSignMessage] = useLocalStorage("signOption", true);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedSignOption = localStorage.getItem("signOption");
|
|
||||||
if (storedSignOption === "true") setSignMessage(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("signOption", signMessage);
|
|
||||||
}, [signMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
@@ -255,7 +248,7 @@ const MessageInput = ({ ticketStatus }) => {
|
|||||||
fromMe: true,
|
fromMe: true,
|
||||||
mediaUrl: "",
|
mediaUrl: "",
|
||||||
body: signMessage
|
body: signMessage
|
||||||
? `*${username}:*\n${inputMessage.trim()}`
|
? `*${user?.name}:*\n${inputMessage.trim()}`
|
||||||
: inputMessage.trim(),
|
: inputMessage.trim(),
|
||||||
quotedMsg: replyingMessage,
|
quotedMsg: replyingMessage,
|
||||||
};
|
};
|
||||||
@@ -279,7 +272,7 @@ const MessageInput = ({ ticketStatus }) => {
|
|||||||
setRecording(true);
|
setRecording(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
toastError(err);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -314,7 +307,7 @@ const MessageInput = ({ ticketStatus }) => {
|
|||||||
await Mp3Recorder.stop().getMp3();
|
await Mp3Recorder.stop().getMp3();
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,8 +421,8 @@ const MessageInput = ({ ticketStatus }) => {
|
|||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={signMessage}
|
checked={signMessage}
|
||||||
onChange={() => {
|
onChange={e => {
|
||||||
setSignMessage(prevState => !prevState);
|
setSignMessage(e.target.checked);
|
||||||
}}
|
}}
|
||||||
name="showAllTickets"
|
name="showAllTickets"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
title={i18n.t("messageOptionsMenu.confirmationModal.title")}
|
title={i18n.t("messageOptionsMenu.confirmationModal.title")}
|
||||||
open={confirmationOpen}
|
open={confirmationOpen}
|
||||||
setOpen={setConfirmationOpen}
|
onClose={setConfirmationOpen}
|
||||||
onConfirm={handleDeleteMessage}
|
onConfirm={handleDeleteMessage}
|
||||||
>
|
>
|
||||||
{i18n.t("messageOptionsMenu.confirmationModal.message")}
|
{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 classes = useStyles();
|
||||||
|
|
||||||
const [messagesList, dispatch] = useReducer(reducer, []);
|
const [messagesList, dispatch] = useReducer(reducer, []);
|
||||||
@@ -354,7 +354,8 @@ const MessagesList = ({ ticketId, isGroup, setReplyingMessage }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
socket.emit("joinChatBox", ticketId);
|
|
||||||
|
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||||
|
|
||||||
socket.on("appMessage", data => {
|
socket.on("appMessage", data => {
|
||||||
if (data.action === "create") {
|
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 { useHistory } from "react-router-dom";
|
||||||
|
|
||||||
import Button from "@material-ui/core/Button";
|
import Button from "@material-ui/core/Button";
|
||||||
@@ -18,6 +18,7 @@ import api from "../../services/api";
|
|||||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||||
import ContactModal from "../ContactModal";
|
import ContactModal from "../ContactModal";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const filter = createFilterOptions({
|
const filter = createFilterOptions({
|
||||||
trim: true,
|
trim: true,
|
||||||
@@ -25,7 +26,6 @@ const filter = createFilterOptions({
|
|||||||
|
|
||||||
const NewTicketModal = ({ modalOpen, onClose }) => {
|
const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
|
|
||||||
const [options, setOptions] = useState([]);
|
const [options, setOptions] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -33,6 +33,7 @@ const NewTicketModal = ({ modalOpen, onClose }) => {
|
|||||||
const [selectedContact, setSelectedContact] = useState(null);
|
const [selectedContact, setSelectedContact] = useState(null);
|
||||||
const [newContact, setNewContact] = useState({});
|
const [newContact, setNewContact] = useState({});
|
||||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen || searchParam.length < 3) {
|
if (!modalOpen || searchParam.length < 3) {
|
||||||
@@ -71,7 +72,7 @@ const NewTicketModal = ({ modalOpen, onClose }) => {
|
|||||||
try {
|
try {
|
||||||
const { data: ticket } = await api.post("/tickets", {
|
const { data: ticket } = await api.post("/tickets", {
|
||||||
contactId: contactId,
|
contactId: contactId,
|
||||||
userId: userId,
|
userId: user.id,
|
||||||
status: "open",
|
status: "open",
|
||||||
});
|
});
|
||||||
history.push(`/tickets/${ticket.id}`);
|
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 { useHistory } from "react-router-dom";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -18,6 +18,7 @@ import TicketListItem from "../TicketListItem";
|
|||||||
import { i18n } from "../../translate/i18n";
|
import { i18n } from "../../translate/i18n";
|
||||||
import useTickets from "../../hooks/useTickets";
|
import useTickets from "../../hooks/useTickets";
|
||||||
import alertSound from "../../assets/sound.mp3";
|
import alertSound from "../../assets/sound.mp3";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
tabContainer: {
|
tabContainer: {
|
||||||
@@ -43,7 +44,7 @@ const NotificationsPopOver = () => {
|
|||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const userId = +localStorage.getItem("userId");
|
const { user } = useContext(AuthContext);
|
||||||
const ticketIdUrl = +history.location.pathname.split("/")[2];
|
const ticketIdUrl = +history.location.pathname.split("/")[2];
|
||||||
const ticketIdRef = useRef(ticketIdUrl);
|
const ticketIdRef = useRef(ticketIdUrl);
|
||||||
const anchorEl = useRef();
|
const anchorEl = useRef();
|
||||||
@@ -56,6 +57,8 @@ const NotificationsPopOver = () => {
|
|||||||
const [play] = useSound(alertSound);
|
const [play] = useSound(alertSound);
|
||||||
const soundAlertRef = useRef();
|
const soundAlertRef = useRef();
|
||||||
|
|
||||||
|
const historyRef = useRef(history);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
soundAlertRef.current = play;
|
soundAlertRef.current = play;
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ const NotificationsPopOver = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
|
|
||||||
socket.emit("joinNotification");
|
socket.on("connect", () => socket.emit("joinNotification"));
|
||||||
|
|
||||||
socket.on("ticket", data => {
|
socket.on("ticket", data => {
|
||||||
if (data.action === "updateUnread" || data.action === "delete") {
|
if (data.action === "updateUnread" || data.action === "delete") {
|
||||||
@@ -108,7 +111,7 @@ const NotificationsPopOver = () => {
|
|||||||
if (
|
if (
|
||||||
data.action === "create" &&
|
data.action === "create" &&
|
||||||
!data.message.read &&
|
!data.message.read &&
|
||||||
(data.ticket.userId === userId || !data.ticket.userId)
|
(data.ticket.userId === user?.id || !data.ticket.userId)
|
||||||
) {
|
) {
|
||||||
setNotifications(prevState => {
|
setNotifications(prevState => {
|
||||||
const ticketIndex = prevState.findIndex(t => t.id === data.ticket.id);
|
const ticketIndex = prevState.findIndex(t => t.id === data.ticket.id);
|
||||||
@@ -122,21 +125,21 @@ const NotificationsPopOver = () => {
|
|||||||
const shouldNotNotificate =
|
const shouldNotNotificate =
|
||||||
(data.message.ticketId === ticketIdRef.current &&
|
(data.message.ticketId === ticketIdRef.current &&
|
||||||
document.visibilityState === "visible") ||
|
document.visibilityState === "visible") ||
|
||||||
(data.ticket.userId && data.ticket.userId !== userId) ||
|
(data.ticket.userId && data.ticket.userId !== user?.id) ||
|
||||||
data.ticket.isGroup;
|
data.ticket.isGroup;
|
||||||
|
|
||||||
if (shouldNotNotificate) return;
|
if (shouldNotNotificate) return;
|
||||||
|
|
||||||
handleNotifications(data, history);
|
handleNotifications(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
}, [history, userId]);
|
}, [user]);
|
||||||
|
|
||||||
const handleNotifications = (data, history) => {
|
const handleNotifications = data => {
|
||||||
const { message, contact, ticket } = data;
|
const { message, contact, ticket } = data;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -154,7 +157,7 @@ const NotificationsPopOver = () => {
|
|||||||
notification.onclick = e => {
|
notification.onclick = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.focus();
|
window.focus();
|
||||||
history.push(`/tickets/${ticket.id}`);
|
historyRef.current.push(`/tickets/${ticket.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
setDesktopNotifications(prevState => {
|
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(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
socket.emit("joinChatBox", ticketId);
|
|
||||||
|
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||||
|
|
||||||
socket.on("ticket", data => {
|
socket.on("ticket", data => {
|
||||||
if (data.action === "updateStatus") {
|
if (data.action === "update") {
|
||||||
setTicket(data.ticket);
|
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 { useHistory } from "react-router-dom";
|
||||||
|
|
||||||
import { makeStyles } from "@material-ui/core/styles";
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
@@ -10,6 +10,7 @@ import api from "../../services/api";
|
|||||||
import TicketOptionsMenu from "../TicketOptionsMenu";
|
import TicketOptionsMenu from "../TicketOptionsMenu";
|
||||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
@@ -26,10 +27,10 @@ const useStyles = makeStyles(theme => ({
|
|||||||
const TicketActionButtons = ({ ticket }) => {
|
const TicketActionButtons = ({ ticket }) => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ticketOptionsMenuOpen = Boolean(anchorEl);
|
const ticketOptionsMenuOpen = Boolean(anchorEl);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
const handleOpenTicketOptionsMenu = e => {
|
const handleOpenTicketOptionsMenu = e => {
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
@@ -66,7 +67,7 @@ const TicketActionButtons = ({ ticket }) => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
startIcon={<Replay />}
|
startIcon={<Replay />}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={e => handleUpdateTicketStatus(e, "open", userId)}
|
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||||
>
|
>
|
||||||
{i18n.t("messagesList.header.buttons.reopen")}
|
{i18n.t("messagesList.header.buttons.reopen")}
|
||||||
</ButtonWithSpinner>
|
</ButtonWithSpinner>
|
||||||
@@ -86,7 +87,7 @@ const TicketActionButtons = ({ ticket }) => {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={e => handleUpdateTicketStatus(e, "closed", userId)}
|
onClick={e => handleUpdateTicketStatus(e, "closed", user?.id)}
|
||||||
>
|
>
|
||||||
{i18n.t("messagesList.header.buttons.resolve")}
|
{i18n.t("messagesList.header.buttons.resolve")}
|
||||||
</ButtonWithSpinner>
|
</ButtonWithSpinner>
|
||||||
@@ -107,7 +108,7 @@ const TicketActionButtons = ({ ticket }) => {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={e => handleUpdateTicketStatus(e, "open", userId)}
|
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||||
>
|
>
|
||||||
{i18n.t("messagesList.header.buttons.accept")}
|
{i18n.t("messagesList.header.buttons.accept")}
|
||||||
</ButtonWithSpinner>
|
</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 { useHistory, useParams } from "react-router-dom";
|
||||||
import { parseISO, format, isSameDay } from "date-fns";
|
import { parseISO, format, isSameDay } from "date-fns";
|
||||||
@@ -19,6 +19,8 @@ import { i18n } from "../../translate/i18n";
|
|||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||||
import MarkdownWrapper from "../MarkdownWrapper";
|
import MarkdownWrapper from "../MarkdownWrapper";
|
||||||
|
import { Tooltip } from "@material-ui/core";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
ticket: {
|
ticket: {
|
||||||
@@ -87,15 +89,24 @@ const useStyles = makeStyles(theme => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ticketQueueColor: {
|
||||||
|
flex: "none",
|
||||||
|
width: "8px",
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
top: "0%",
|
||||||
|
left: "0%",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const TicketListItem = ({ ticket }) => {
|
const TicketListItem = ({ ticket }) => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { ticketId } = useParams();
|
const { ticketId } = useParams();
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -108,7 +119,7 @@ const TicketListItem = ({ ticket }) => {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/tickets/${ticketId}`, {
|
await api.put(`/tickets/${ticketId}`, {
|
||||||
status: "open",
|
status: "open",
|
||||||
userId: userId,
|
userId: user?.id,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -138,6 +149,16 @@ const TicketListItem = ({ ticket }) => {
|
|||||||
[classes.pendingTicket]: ticket.status === "pending",
|
[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>
|
<ListItemAvatar>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={ticket.contact.profilePicUrl && ticket.contact.profilePicUrl}
|
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 MenuItem from "@material-ui/core/MenuItem";
|
||||||
import Menu from "@material-ui/core/Menu";
|
import Menu from "@material-ui/core/Menu";
|
||||||
@@ -8,11 +8,14 @@ import api from "../../services/api";
|
|||||||
import ConfirmationModal from "../ConfirmationModal";
|
import ConfirmationModal from "../ConfirmationModal";
|
||||||
import TransferTicketModal from "../TransferTicketModal";
|
import TransferTicketModal from "../TransferTicketModal";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import { Can } from "../Can";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||||
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||||
const [transferTicketModalOpen, setTransferTicketModalOpen] = useState(false);
|
const [transferTicketModalOpen, setTransferTicketModalOpen] = useState(false);
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -62,9 +65,16 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
|||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
|
<Can
|
||||||
|
role={user.profile}
|
||||||
|
perform="ticket-options:deleteTicket"
|
||||||
|
yes={() => (
|
||||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||||
{i18n.t("ticketOptionsMenu.delete")}
|
{i18n.t("ticketOptionsMenu.delete")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<MenuItem onClick={handleOpenTransferModal}>
|
<MenuItem onClick={handleOpenTransferModal}>
|
||||||
{i18n.t("ticketOptionsMenu.transfer")}
|
{i18n.t("ticketOptionsMenu.transfer")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -76,7 +86,7 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
|||||||
ticket.contact.name
|
ticket.contact.name
|
||||||
}?`}
|
}?`}
|
||||||
open={confirmationOpen}
|
open={confirmationOpen}
|
||||||
setOpen={setConfirmationOpen}
|
onClose={setConfirmationOpen}
|
||||||
onConfirm={handleDeleteTicket}
|
onConfirm={handleDeleteTicket}
|
||||||
>
|
>
|
||||||
{i18n.t("ticketOptionsMenu.confirmationModal.message")}
|
{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 openSocket from "socket.io-client";
|
||||||
|
|
||||||
import { makeStyles } from "@material-ui/core/styles";
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
@@ -7,9 +7,11 @@ import Paper from "@material-ui/core/Paper";
|
|||||||
|
|
||||||
import TicketListItem from "../TicketListItem";
|
import TicketListItem from "../TicketListItem";
|
||||||
import TicketsListSkeleton from "../TicketsListSkeleton";
|
import TicketsListSkeleton from "../TicketsListSkeleton";
|
||||||
|
|
||||||
import useTickets from "../../hooks/useTickets";
|
import useTickets from "../../hooks/useTickets";
|
||||||
import { i18n } from "../../translate/i18n";
|
import { i18n } from "../../translate/i18n";
|
||||||
import { ListSubheader } from "@material-ui/core";
|
import { ListSubheader } from "@material-ui/core";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
ticketsListWrapper: {
|
ticketsListWrapper: {
|
||||||
@@ -34,6 +36,9 @@ const useStyles = makeStyles(theme => ({
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
|
|
||||||
ticketsCount: {
|
ticketsCount: {
|
||||||
@@ -110,14 +115,14 @@ const reducer = (state, action) => {
|
|||||||
return [...state];
|
return [...state];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "UPDATE_TICKET_MESSAGES_COUNT") {
|
if (action.type === "UPDATE_TICKET_UNREAD_MESSAGES") {
|
||||||
const { ticket, searchParam } = action.payload;
|
const ticket = action.payload;
|
||||||
|
|
||||||
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
||||||
if (ticketIndex !== -1) {
|
if (ticketIndex !== -1) {
|
||||||
state[ticketIndex] = ticket;
|
state[ticketIndex] = ticket;
|
||||||
state.unshift(state.splice(ticketIndex, 1)[0]);
|
state.unshift(state.splice(ticketIndex, 1)[0]);
|
||||||
} else if (!searchParam) {
|
} else {
|
||||||
state.unshift(ticket);
|
state.unshift(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,38 +153,47 @@ const reducer = (state, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const TicketsList = ({ status, searchParam, showAll }) => {
|
const TicketsList = ({ status, searchParam, showAll, selectedQueueIds }) => {
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(1);
|
||||||
const [ticketsList, dispatch] = useReducer(reducer, []);
|
const [ticketsList, dispatch] = useReducer(reducer, []);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "RESET" });
|
dispatch({ type: "RESET" });
|
||||||
setPageNumber(1);
|
setPageNumber(1);
|
||||||
}, [status, searchParam, dispatch, showAll]);
|
}, [status, searchParam, dispatch, showAll, selectedQueueIds]);
|
||||||
|
|
||||||
const { tickets, hasMore, loading } = useTickets({
|
const { tickets, hasMore, loading } = useTickets({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
searchParam,
|
searchParam,
|
||||||
status,
|
status,
|
||||||
showAll,
|
showAll,
|
||||||
|
queueIds: JSON.stringify(selectedQueueIds),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!status && !searchParam) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "LOAD_TICKETS",
|
type: "LOAD_TICKETS",
|
||||||
payload: tickets,
|
payload: tickets,
|
||||||
});
|
});
|
||||||
}, [tickets]);
|
}, [tickets, status, searchParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
|
|
||||||
|
const shouldUpdateTicket = ticket =>
|
||||||
|
(!ticket.userId || ticket.userId === user?.id || showAll) &&
|
||||||
|
(!ticket.queueId || selectedQueueIds.indexOf(ticket.queueId) > -1);
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
if (status) {
|
if (status) {
|
||||||
socket.emit("joinTickets", status);
|
socket.emit("joinTickets", status);
|
||||||
} else {
|
} else {
|
||||||
socket.emit("joinNotification");
|
socket.emit("joinNotification");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("ticket", data => {
|
socket.on("ticket", data => {
|
||||||
if (data.action === "updateUnread") {
|
if (data.action === "updateUnread") {
|
||||||
@@ -189,10 +203,7 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (data.action === "update" && shouldUpdateTicket(data.ticket)) {
|
||||||
(data.action === "updateStatus" || data.action === "create") &&
|
|
||||||
(!data.ticket.userId || data.ticket.userId === userId || showAll)
|
|
||||||
) {
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TICKET",
|
type: "UPDATE_TICKET",
|
||||||
payload: data.ticket,
|
payload: data.ticket,
|
||||||
@@ -205,16 +216,10 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("appMessage", data => {
|
socket.on("appMessage", data => {
|
||||||
if (
|
if (data.action === "create" && shouldUpdateTicket(data.ticket)) {
|
||||||
data.action === "create" &&
|
|
||||||
(!data.ticket.userId || data.ticket.userId === userId || showAll)
|
|
||||||
) {
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TICKET_MESSAGES_COUNT",
|
type: "UPDATE_TICKET_UNREAD_MESSAGES",
|
||||||
payload: {
|
payload: data.ticket,
|
||||||
ticket: data.ticket,
|
|
||||||
searchParam,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -231,7 +236,7 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
}, [status, showAll, userId, searchParam]);
|
}, [status, showAll, user, selectedQueueIds]);
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
setPageNumber(prevState => prevState + 1);
|
setPageNumber(prevState => prevState + 1);
|
||||||
@@ -259,14 +264,22 @@ const TicketsList = ({ status, searchParam, showAll }) => {
|
|||||||
<List style={{ paddingTop: 0 }}>
|
<List style={{ paddingTop: 0 }}>
|
||||||
{status === "open" && (
|
{status === "open" && (
|
||||||
<ListSubheader className={classes.ticketsListHeader}>
|
<ListSubheader className={classes.ticketsListHeader}>
|
||||||
|
<div>
|
||||||
{i18n.t("ticketsList.assignedHeader")}
|
{i18n.t("ticketsList.assignedHeader")}
|
||||||
<span className={classes.ticketsCount}>{ticketsList.length}</span>
|
<span className={classes.ticketsCount}>
|
||||||
|
{ticketsList.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</ListSubheader>
|
</ListSubheader>
|
||||||
)}
|
)}
|
||||||
{status === "pending" && (
|
{status === "pending" && (
|
||||||
<ListSubheader className={classes.ticketsListHeader}>
|
<ListSubheader className={classes.ticketsListHeader}>
|
||||||
|
<div>
|
||||||
{i18n.t("ticketsList.pendingHeader")}
|
{i18n.t("ticketsList.pendingHeader")}
|
||||||
<span className={classes.ticketsCount}>{ticketsList.length}</span>
|
<span className={classes.ticketsCount}>
|
||||||
|
{ticketsList.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</ListSubheader>
|
</ListSubheader>
|
||||||
)}
|
)}
|
||||||
{ticketsList.length === 0 && !loading ? (
|
{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 { makeStyles } from "@material-ui/core/styles";
|
||||||
import Paper from "@material-ui/core/Paper";
|
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 Tab from "@material-ui/core/Tab";
|
||||||
import MoveToInboxIcon from "@material-ui/icons/MoveToInbox";
|
import MoveToInboxIcon from "@material-ui/icons/MoveToInbox";
|
||||||
import CheckBoxIcon from "@material-ui/icons/CheckBox";
|
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 FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
import Switch from "@material-ui/core/Switch";
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
|
||||||
@@ -18,6 +17,11 @@ import TicketsList from "../TicketsList";
|
|||||||
import TabPanel from "../TabPanel";
|
import TabPanel from "../TabPanel";
|
||||||
|
|
||||||
import { i18n } from "../../translate/i18n";
|
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 => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
ticketsWrapper: {
|
ticketsWrapper: {
|
||||||
@@ -46,17 +50,12 @@ const useStyles = makeStyles(theme => ({
|
|||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
|
|
||||||
ticketsListActions: {
|
ticketOptionsBox: {
|
||||||
flex: "none",
|
|
||||||
marginLeft: "auto",
|
|
||||||
},
|
|
||||||
|
|
||||||
searchBox: {
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
background: "#fafafa",
|
background: "#fafafa",
|
||||||
padding: "10px 13px",
|
padding: theme.spacing(1),
|
||||||
},
|
},
|
||||||
|
|
||||||
serachInputWrapper: {
|
serachInputWrapper: {
|
||||||
@@ -65,6 +64,7 @@ const useStyles = makeStyles(theme => ({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
borderRadius: 40,
|
borderRadius: 40,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
},
|
},
|
||||||
|
|
||||||
searchIcon: {
|
searchIcon: {
|
||||||
@@ -88,15 +88,35 @@ const TicketsManager = () => {
|
|||||||
const [tab, setTab] = useState("open");
|
const [tab, setTab] = useState("open");
|
||||||
const [newTicketModalOpen, setNewTicketModalOpen] = useState(false);
|
const [newTicketModalOpen, setNewTicketModalOpen] = useState(false);
|
||||||
const [showAllTickets, setShowAllTickets] = useState(false);
|
const [showAllTickets, setShowAllTickets] = useState(false);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
const searchInputRef = useRef();
|
||||||
|
const [selectedQueueIds, setSelectedQueueIds] = useLocalStorage(
|
||||||
|
"selectedQueueIds",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchContact = e => {
|
useEffect(() => {
|
||||||
if (e.target.value === "") {
|
if (tab === "search") {
|
||||||
setSearchParam(e.target.value.toLowerCase());
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
const handleSearch = e => {
|
||||||
|
const searchedTerm = e.target.value.toLowerCase();
|
||||||
|
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
|
||||||
|
if (searchedTerm === "") {
|
||||||
|
setSearchParam(searchedTerm);
|
||||||
setTab("open");
|
setTab("open");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearchParam(e.target.value.toLowerCase());
|
|
||||||
setTab("search");
|
searchTimeout = setTimeout(() => {
|
||||||
|
setSearchParam(searchedTerm);
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeTab = (e, newValue) => {
|
const handleChangeTab = (e, newValue) => {
|
||||||
@@ -138,17 +158,31 @@ const TicketsManager = () => {
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Paper square elevation={0} className={classes.searchBox}>
|
<Paper square elevation={0} className={classes.ticketOptionsBox}>
|
||||||
|
{tab === "search" ? (
|
||||||
<div className={classes.serachInputWrapper}>
|
<div className={classes.serachInputWrapper}>
|
||||||
<SearchIcon className={classes.searchIcon} />
|
<SearchIcon className={classes.searchIcon} />
|
||||||
<InputBase
|
<InputBase
|
||||||
className={classes.searchInput}
|
className={classes.searchInput}
|
||||||
|
inputRef={searchInputRef}
|
||||||
placeholder={i18n.t("tickets.search.placeholder")}
|
placeholder={i18n.t("tickets.search.placeholder")}
|
||||||
type="search"
|
type="search"
|
||||||
onChange={handleSearchContact}
|
onChange={handleSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.ticketsListActions}>
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setNewTicketModalOpen(true)}
|
||||||
|
>
|
||||||
|
Novo
|
||||||
|
</Button>
|
||||||
|
<Can
|
||||||
|
role={user.profile}
|
||||||
|
perform="tickets-manager:showall"
|
||||||
|
yes={() => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
label={i18n.t("tickets.buttons.showAll")}
|
label={i18n.t("tickets.buttons.showAll")}
|
||||||
labelPlacement="start"
|
labelPlacement="start"
|
||||||
@@ -156,32 +190,46 @@ const TicketsManager = () => {
|
|||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={showAllTickets}
|
checked={showAllTickets}
|
||||||
onChange={() => setShowAllTickets(prevState => !prevState)}
|
onChange={() =>
|
||||||
|
setShowAllTickets(prevState => !prevState)
|
||||||
|
}
|
||||||
name="showAllTickets"
|
name="showAllTickets"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
)}
|
||||||
aria-label="add ticket"
|
/>
|
||||||
size="small"
|
</>
|
||||||
color="primary"
|
)}
|
||||||
onClick={e => setNewTicketModalOpen(true)}
|
<TicketsQueueSelect
|
||||||
style={{ marginLeft: 20 }}
|
style={{ marginLeft: 6 }}
|
||||||
>
|
selectedQueueIds={selectedQueueIds}
|
||||||
<AddIcon />
|
userQueues={user?.queues}
|
||||||
</IconButton>
|
onChange={values => setSelectedQueueIds(values)}
|
||||||
</div>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
<TabPanel value={tab} name="open" className={classes.ticketsWrapper}>
|
<TabPanel value={tab} name="open" className={classes.ticketsWrapper}>
|
||||||
<TicketsList status="open" showAll={showAllTickets} />
|
<TicketsList
|
||||||
<TicketsList status="pending" showAll={true} />
|
status="open"
|
||||||
|
showAll={showAllTickets}
|
||||||
|
selectedQueueIds={selectedQueueIds}
|
||||||
|
/>
|
||||||
|
<TicketsList status="pending" selectedQueueIds={selectedQueueIds} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} name="closed" className={classes.ticketsWrapper}>
|
<TabPanel value={tab} name="closed" className={classes.ticketsWrapper}>
|
||||||
<TicketsList status="closed" showAll={true} />
|
<TicketsList
|
||||||
|
status="closed"
|
||||||
|
showAll={true}
|
||||||
|
selectedQueueIds={selectedQueueIds}
|
||||||
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} name="search" className={classes.ticketsWrapper}>
|
<TabPanel value={tab} name="search" className={classes.ticketsWrapper}>
|
||||||
<TicketsList searchParam={searchParam} showAll={true} />
|
<TicketsList
|
||||||
|
searchParam={searchParam}
|
||||||
|
showAll={true}
|
||||||
|
selectedQueueIds={selectedQueueIds}
|
||||||
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Paper>
|
</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 * as Yup from "yup";
|
||||||
import { Formik, Form, Field } from "formik";
|
import { Formik, Form, Field } from "formik";
|
||||||
@@ -22,15 +22,20 @@ import { i18n } from "../../translate/i18n";
|
|||||||
|
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import QueueSelect from "../QueueSelect";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
import { Can } from "../Can";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
root: {
|
root: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
textField: {
|
multFieldLine: {
|
||||||
|
display: "flex",
|
||||||
|
"& > *:not(:last-child)": {
|
||||||
marginRight: theme.spacing(1),
|
marginRight: theme.spacing(1),
|
||||||
flex: 1,
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
btnWrapper: {
|
btnWrapper: {
|
||||||
@@ -70,7 +75,10 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
profile: "user",
|
profile: "user",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { user: loggedInUser } = useContext(AuthContext);
|
||||||
|
|
||||||
const [user, setUser] = useState(initialState);
|
const [user, setUser] = useState(initialState);
|
||||||
|
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
@@ -80,6 +88,8 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
setUser(prevState => {
|
setUser(prevState => {
|
||||||
return { ...prevState, ...data };
|
return { ...prevState, ...data };
|
||||||
});
|
});
|
||||||
|
const userQueueIds = data.queues?.map(queue => queue.id);
|
||||||
|
setSelectedQueueIds(userQueueIds);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
@@ -94,11 +104,12 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveUser = async values => {
|
const handleSaveUser = async values => {
|
||||||
|
const userData = { ...values, queueIds: selectedQueueIds };
|
||||||
try {
|
try {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await api.put(`/users/${userId}`, values);
|
await api.put(`/users/${userId}`, userData);
|
||||||
} else {
|
} else {
|
||||||
await api.post("/users", values);
|
await api.post("/users", userData);
|
||||||
}
|
}
|
||||||
toast.success(i18n.t("userModal.success"));
|
toast.success(i18n.t("userModal.success"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,7 +120,13 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<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">
|
<DialogTitle id="form-dialog-title">
|
||||||
{userId
|
{userId
|
||||||
? `${i18n.t("userModal.title.edit")}`
|
? `${i18n.t("userModal.title.edit")}`
|
||||||
@@ -129,6 +146,7 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
{({ touched, errors, isSubmitting }) => (
|
{({ touched, errors, isSubmitting }) => (
|
||||||
<Form>
|
<Form>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
|
<div className={classes.multFieldLine}>
|
||||||
<Field
|
<Field
|
||||||
as={TextField}
|
as={TextField}
|
||||||
label={i18n.t("userModal.form.name")}
|
label={i18n.t("userModal.form.name")}
|
||||||
@@ -138,18 +156,8 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
helperText={touched.name && errors.name}
|
helperText={touched.name && errors.name}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
margin="dense"
|
margin="dense"
|
||||||
className={classes.textField}
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<Field
|
<Field
|
||||||
as={TextField}
|
as={TextField}
|
||||||
label={i18n.t("userModal.form.password")}
|
label={i18n.t("userModal.form.password")}
|
||||||
@@ -159,15 +167,34 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
helperText={touched.password && errors.password}
|
helperText={touched.password && errors.password}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
margin="dense"
|
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
|
<FormControl
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className={classes.formControl}
|
className={classes.formControl}
|
||||||
margin="dense"
|
margin="dense"
|
||||||
>
|
>
|
||||||
|
<Can
|
||||||
|
role={loggedInUser.profile}
|
||||||
|
perform="user-modal:editProfile"
|
||||||
|
yes={() => (
|
||||||
|
<>
|
||||||
<InputLabel id="profile-selection-input-label">
|
<InputLabel id="profile-selection-input-label">
|
||||||
{i18n.t("userModal.form.profile")}
|
{i18n.t("userModal.form.profile")}
|
||||||
</InputLabel>
|
</InputLabel>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
as={Select}
|
as={Select}
|
||||||
label={i18n.t("userModal.form.profile")}
|
label={i18n.t("userModal.form.profile")}
|
||||||
@@ -179,8 +206,21 @@ const UserModal = ({ open, onClose, userId }) => {
|
|||||||
<MenuItem value="admin">Admin</MenuItem>
|
<MenuItem value="admin">Admin</MenuItem>
|
||||||
<MenuItem value="user">User</MenuItem>
|
<MenuItem value="user">User</MenuItem>
|
||||||
</Field>
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
<Can
|
||||||
|
role={loggedInUser.profile}
|
||||||
|
perform="user-modal:editQueues"
|
||||||
|
yes={() => (
|
||||||
|
<QueueSelect
|
||||||
|
selectedQueueIds={selectedQueueIds}
|
||||||
|
onChange={values => setSelectedQueueIds(values)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -21,19 +21,19 @@ import {
|
|||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import { i18n } from "../../translate/i18n";
|
import { i18n } from "../../translate/i18n";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import QueueSelect from "../QueueSelect";
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
form: {
|
root: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexWrap: "wrap",
|
||||||
justifySelf: "center",
|
|
||||||
"& > *": {
|
|
||||||
margin: theme.spacing(1),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
textField: {
|
multFieldLine: {
|
||||||
flex: 1,
|
display: "flex",
|
||||||
|
"& > *:not(:last-child)": {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
btnWrapper: {
|
btnWrapper: {
|
||||||
@@ -61,9 +61,11 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const initialState = {
|
const initialState = {
|
||||||
name: "",
|
name: "",
|
||||||
|
greetingMessage: "",
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
};
|
};
|
||||||
const [whatsApp, setWhatsApp] = useState(initialState);
|
const [whatsApp, setWhatsApp] = useState(initialState);
|
||||||
|
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSession = async () => {
|
const fetchSession = async () => {
|
||||||
@@ -72,6 +74,9 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get(`whatsapp/${whatsAppId}`);
|
const { data } = await api.get(`whatsapp/${whatsAppId}`);
|
||||||
setWhatsApp(data);
|
setWhatsApp(data);
|
||||||
|
|
||||||
|
const whatsQueueIds = data.queues?.map(queue => queue.id);
|
||||||
|
setSelectedQueueIds(whatsQueueIds);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
@@ -80,20 +85,19 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
}, [whatsAppId]);
|
}, [whatsAppId]);
|
||||||
|
|
||||||
const handleSaveWhatsApp = async values => {
|
const handleSaveWhatsApp = async values => {
|
||||||
|
const whatsappData = { ...values, queueIds: selectedQueueIds };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (whatsAppId) {
|
if (whatsAppId) {
|
||||||
await api.put(`/whatsapp/${whatsAppId}`, {
|
await api.put(`/whatsapp/${whatsAppId}`, whatsappData);
|
||||||
name: values.name,
|
|
||||||
isDefault: values.isDefault,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await api.post("/whatsapp", values);
|
await api.post("/whatsapp", whatsappData);
|
||||||
}
|
}
|
||||||
toast.success(i18n.t("whatsappModal.success"));
|
toast.success(i18n.t("whatsappModal.success"));
|
||||||
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
handleClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -102,7 +106,14 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
<div className={classes.root}>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
scroll="paper"
|
||||||
|
>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{whatsAppId
|
{whatsAppId
|
||||||
? i18n.t("whatsappModal.title.edit")
|
? i18n.t("whatsappModal.title.edit")
|
||||||
@@ -121,7 +132,8 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
>
|
>
|
||||||
{({ values, touched, errors, isSubmitting }) => (
|
{({ values, touched, errors, isSubmitting }) => (
|
||||||
<Form>
|
<Form>
|
||||||
<DialogContent dividers className={classes.form}>
|
<DialogContent dividers>
|
||||||
|
<div className={classes.multFieldLine}>
|
||||||
<Field
|
<Field
|
||||||
as={TextField}
|
as={TextField}
|
||||||
label={i18n.t("whatsappModal.form.name")}
|
label={i18n.t("whatsappModal.form.name")}
|
||||||
@@ -144,6 +156,30 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
}
|
}
|
||||||
label={i18n.t("whatsappModal.form.default")}
|
label={i18n.t("whatsappModal.form.default")}
|
||||||
/>
|
/>
|
||||||
|
</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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
@@ -176,6 +212,7 @@ const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
|||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import useAuth from "./useAuth";
|
|||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
const AuthProvider = ({ children }) => {
|
const AuthProvider = ({ children }) => {
|
||||||
const { isAuth, loading, handleLogin, handleLogout } = useAuth();
|
const { loading, user, isAuth, handleLogin, handleLogout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ loading, isAuth, handleLogin, handleLogout }}
|
value={{ loading, user, isAuth, handleLogin, handleLogout }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
|
import openSocket from "socket.io-client";
|
||||||
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ const useAuth = () => {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [isAuth, setIsAuth] = useState(false);
|
const [isAuth, setIsAuth] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [user, setUser] = useState({});
|
||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
config => {
|
config => {
|
||||||
@@ -44,9 +46,6 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("username");
|
|
||||||
localStorage.removeItem("profile");
|
|
||||||
localStorage.removeItem("userId");
|
|
||||||
api.defaults.headers.Authorization = undefined;
|
api.defaults.headers.Authorization = undefined;
|
||||||
setIsAuth(false);
|
setIsAuth(false);
|
||||||
}
|
}
|
||||||
@@ -56,49 +55,64 @@ const useAuth = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
(async () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`;
|
try {
|
||||||
|
const { data } = await api.post("/auth/refresh_token");
|
||||||
|
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||||
setIsAuth(true);
|
setIsAuth(true);
|
||||||
|
setUser(data.user);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
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);
|
setLoading(true);
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/auth/login", user);
|
const { data } = await api.post("/auth/login", user);
|
||||||
localStorage.setItem("token", JSON.stringify(data.token));
|
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}`;
|
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||||
|
setUser(data.user);
|
||||||
setIsAuth(true);
|
setIsAuth(true);
|
||||||
toast.success(i18n.t("auth.toasts.success"));
|
toast.success(i18n.t("auth.toasts.success"));
|
||||||
history.push("/tickets");
|
history.push("/tickets");
|
||||||
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = e => {
|
const handleLogout = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
e.preventDefault();
|
|
||||||
setIsAuth(false);
|
setIsAuth(false);
|
||||||
|
setUser({});
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("username");
|
|
||||||
localStorage.removeItem("profile");
|
|
||||||
localStorage.removeItem("userId");
|
|
||||||
api.defaults.headers.Authorization = undefined;
|
api.defaults.headers.Authorization = undefined;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
history.push("/login");
|
history.push("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
return { isAuth, setIsAuth, loading, handleLogin, handleLogout };
|
return { isAuth, user, loading, handleLogin, handleLogout };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useAuth;
|
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,
|
status,
|
||||||
date,
|
date,
|
||||||
showAll,
|
showAll,
|
||||||
|
queueIds,
|
||||||
withUnreadMessages,
|
withUnreadMessages,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -27,6 +28,7 @@ const useTickets = ({
|
|||||||
status,
|
status,
|
||||||
date,
|
date,
|
||||||
showAll,
|
showAll,
|
||||||
|
queueIds,
|
||||||
withUnreadMessages,
|
withUnreadMessages,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -41,7 +43,15 @@ const useTickets = ({
|
|||||||
fetchTickets();
|
fetchTickets();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(delayDebounceFn);
|
return () => clearTimeout(delayDebounceFn);
|
||||||
}, [searchParam, pageNumber, status, date, showAll, withUnreadMessages]);
|
}, [
|
||||||
|
searchParam,
|
||||||
|
pageNumber,
|
||||||
|
status,
|
||||||
|
date,
|
||||||
|
showAll,
|
||||||
|
queueIds,
|
||||||
|
withUnreadMessages,
|
||||||
|
]);
|
||||||
|
|
||||||
return { tickets, loading, hasMore };
|
return { tickets, loading, hasMore };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import ListItemIcon from "@material-ui/core/ListItemIcon";
|
|||||||
import ListItemText from "@material-ui/core/ListItemText";
|
import ListItemText from "@material-ui/core/ListItemText";
|
||||||
import ListSubheader from "@material-ui/core/ListSubheader";
|
import ListSubheader from "@material-ui/core/ListSubheader";
|
||||||
import Divider from "@material-ui/core/Divider";
|
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 WhatsAppIcon from "@material-ui/icons/WhatsApp";
|
||||||
import SyncAltIcon from "@material-ui/icons/SyncAlt";
|
import SyncAltIcon from "@material-ui/icons/SyncAlt";
|
||||||
import SettingsIcon from "@material-ui/icons/Settings";
|
import SettingsOutlinedIcon from "@material-ui/icons/SettingsOutlined";
|
||||||
import GroupIcon from "@material-ui/icons/Group";
|
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 { WhatsAppsContext } from "../context/WhatsApp/WhatsAppsContext";
|
||||||
import { i18n } from "../../translate/i18n";
|
import { AuthContext } from "../context/Auth/AuthContext";
|
||||||
import { Badge } from "@material-ui/core";
|
import { Can } from "../components/Can";
|
||||||
|
|
||||||
import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext";
|
|
||||||
|
|
||||||
function ListItemLink(props) {
|
function ListItemLink(props) {
|
||||||
const { icon, primary, to, className } = props;
|
const { icon, primary, to, className } = props;
|
||||||
@@ -41,8 +42,8 @@ function ListItemLink(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MainListItems = () => {
|
const MainListItems = () => {
|
||||||
const userProfile = localStorage.getItem("profile");
|
|
||||||
const { whatsApps } = useContext(WhatsAppsContext);
|
const { whatsApps } = useContext(WhatsAppsContext);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
const [connectionWarning, setConnectionWarning] = useState(false);
|
const [connectionWarning, setConnectionWarning] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,7 +72,11 @@ const MainListItems = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ListItemLink to="/" primary="Dashboard" icon={<DashboardIcon />} />
|
<ListItemLink
|
||||||
|
to="/"
|
||||||
|
primary="Dashboard"
|
||||||
|
icon={<DashboardOutlinedIcon />}
|
||||||
|
/>
|
||||||
<ListItemLink
|
<ListItemLink
|
||||||
to="/connections"
|
to="/connections"
|
||||||
primary={i18n.t("mainDrawer.listItems.connections")}
|
primary={i18n.t("mainDrawer.listItems.connections")}
|
||||||
@@ -90,9 +95,12 @@ const MainListItems = () => {
|
|||||||
<ListItemLink
|
<ListItemLink
|
||||||
to="/contacts"
|
to="/contacts"
|
||||||
primary={i18n.t("mainDrawer.listItems.contacts")}
|
primary={i18n.t("mainDrawer.listItems.contacts")}
|
||||||
icon={<ContactPhoneIcon />}
|
icon={<ContactPhoneOutlinedIcon />}
|
||||||
/>
|
/>
|
||||||
{userProfile === "admin" && (
|
<Can
|
||||||
|
role={user.profile}
|
||||||
|
perform="drawer-admin-items:view"
|
||||||
|
yes={() => (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListSubheader inset>
|
<ListSubheader inset>
|
||||||
@@ -101,15 +109,21 @@ const MainListItems = () => {
|
|||||||
<ListItemLink
|
<ListItemLink
|
||||||
to="/users"
|
to="/users"
|
||||||
primary={i18n.t("mainDrawer.listItems.users")}
|
primary={i18n.t("mainDrawer.listItems.users")}
|
||||||
icon={<GroupIcon />}
|
icon={<PeopleAltOutlinedIcon />}
|
||||||
|
/>
|
||||||
|
<ListItemLink
|
||||||
|
to="/queues"
|
||||||
|
primary={i18n.t("mainDrawer.listItems.queues")}
|
||||||
|
icon={<AccountTreeOutlinedIcon />}
|
||||||
/>
|
/>
|
||||||
<ListItemLink
|
<ListItemLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||||
icon={<SettingsIcon />}
|
icon={<SettingsOutlinedIcon />}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useContext, useEffect } from "react";
|
import React, { useState, useContext } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,11 +19,12 @@ import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
|||||||
import AccountCircle from "@material-ui/icons/AccountCircle";
|
import AccountCircle from "@material-ui/icons/AccountCircle";
|
||||||
|
|
||||||
import MainListItems from "./MainListItems";
|
import MainListItems from "./MainListItems";
|
||||||
import NotificationsPopOver from "../NotificationsPopOver";
|
import NotificationsPopOver from "../components/NotificationsPopOver";
|
||||||
import UserModal from "../UserModal";
|
import UserModal from "../components/UserModal";
|
||||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
import { AuthContext } from "../context/Auth/AuthContext";
|
||||||
import BackdropLoading from "../BackdropLoading";
|
import BackdropLoading from "../components/BackdropLoading";
|
||||||
import { i18n } from "../../translate/i18n";
|
import { i18n } from "../translate/i18n";
|
||||||
|
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
|
||||||
@@ -107,30 +108,13 @@ const useStyles = makeStyles(theme => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const LoggedInLayout = ({ children }) => {
|
const LoggedInLayout = ({ children }) => {
|
||||||
const drawerState = localStorage.getItem("drawerOpen");
|
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const { handleLogout, loading } = useContext(AuthContext);
|
const { handleLogout, loading } = useContext(AuthContext);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useLocalStorage("drawerOpen", true);
|
||||||
useEffect(() => {
|
const { user } = useContext(AuthContext);
|
||||||
if (drawerState === "0") {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}, [drawerState]);
|
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
|
||||||
setOpen(true);
|
|
||||||
localStorage.setItem("drawerOpen", 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
|
||||||
setOpen(false);
|
|
||||||
localStorage.setItem("drawerOpen", 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMenu = event => {
|
const handleMenu = event => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@@ -156,12 +140,15 @@ const LoggedInLayout = ({ children }) => {
|
|||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
classes={{
|
classes={{
|
||||||
paper: clsx(classes.drawerPaper, !open && classes.drawerPaperClose),
|
paper: clsx(
|
||||||
|
classes.drawerPaper,
|
||||||
|
!drawerOpen && classes.drawerPaperClose
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
open={open}
|
open={drawerOpen}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbarIcon}>
|
<div className={classes.toolbarIcon}>
|
||||||
<IconButton onClick={handleDrawerClose}>
|
<IconButton onClick={() => setDrawerOpen(!drawerOpen)}>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,11 +161,11 @@ const LoggedInLayout = ({ children }) => {
|
|||||||
<UserModal
|
<UserModal
|
||||||
open={userModalOpen}
|
open={userModalOpen}
|
||||||
onClose={() => setUserModalOpen(false)}
|
onClose={() => setUserModalOpen(false)}
|
||||||
userId={userId}
|
userId={user?.id}
|
||||||
/>
|
/>
|
||||||
<AppBar
|
<AppBar
|
||||||
position="absolute"
|
position="absolute"
|
||||||
className={clsx(classes.appBar, open && classes.appBarShift)}
|
className={clsx(classes.appBar, drawerOpen && classes.appBarShift)}
|
||||||
color={process.env.NODE_ENV === "development" ? "inherit" : "primary"}
|
color={process.env.NODE_ENV === "development" ? "inherit" : "primary"}
|
||||||
>
|
>
|
||||||
<Toolbar variant="dense" className={classes.toolbar}>
|
<Toolbar variant="dense" className={classes.toolbar}>
|
||||||
@@ -186,10 +173,10 @@ const LoggedInLayout = ({ children }) => {
|
|||||||
edge="start"
|
edge="start"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label="open drawer"
|
aria-label="open drawer"
|
||||||
onClick={handleDrawerOpen}
|
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
classes.menuButton,
|
classes.menuButton,
|
||||||
open && classes.menuButtonHidden
|
drawerOpen && classes.menuButtonHidden
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
@@ -294,7 +294,7 @@ const Connections = () => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
title={confirmModalInfo.title}
|
title={confirmModalInfo.title}
|
||||||
open={confirmModalOpen}
|
open={confirmModalOpen}
|
||||||
setOpen={setConfirmModalOpen}
|
onClose={setConfirmModalOpen}
|
||||||
onConfirm={handleSubmitConfirmationModal}
|
onConfirm={handleSubmitConfirmationModal}
|
||||||
>
|
>
|
||||||
{confirmModalInfo.message}
|
{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 openSocket from "socket.io-client";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
@@ -32,6 +32,8 @@ import Title from "../../components/Title";
|
|||||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||||
import MainContainer from "../../components/MainContainer";
|
import MainContainer from "../../components/MainContainer";
|
||||||
import toastError from "../../errors/toastError";
|
import toastError from "../../errors/toastError";
|
||||||
|
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||||
|
import { Can } from "../../components/Can";
|
||||||
|
|
||||||
const reducer = (state, action) => {
|
const reducer = (state, action) => {
|
||||||
if (action.type === "LOAD_CONTACTS") {
|
if (action.type === "LOAD_CONTACTS") {
|
||||||
@@ -89,7 +91,8 @@ const useStyles = makeStyles(theme => ({
|
|||||||
const Contacts = () => {
|
const Contacts = () => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const userId = +localStorage.getItem("userId");
|
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(1);
|
||||||
@@ -128,6 +131,7 @@ const Contacts = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
|
|
||||||
socket.on("contact", data => {
|
socket.on("contact", data => {
|
||||||
if (data.action === "update" || data.action === "create") {
|
if (data.action === "update" || data.action === "create") {
|
||||||
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
|
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
|
||||||
@@ -163,7 +167,7 @@ const Contacts = () => {
|
|||||||
try {
|
try {
|
||||||
const { data: ticket } = await api.post("/tickets", {
|
const { data: ticket } = await api.post("/tickets", {
|
||||||
contactId: contactId,
|
contactId: contactId,
|
||||||
userId: userId,
|
userId: user?.id,
|
||||||
status: "open",
|
status: "open",
|
||||||
});
|
});
|
||||||
history.push(`/tickets/${ticket.id}`);
|
history.push(`/tickets/${ticket.id}`);
|
||||||
@@ -228,7 +232,7 @@ const Contacts = () => {
|
|||||||
: `${i18n.t("contacts.confirmationModal.importTitlte")}`
|
: `${i18n.t("contacts.confirmationModal.importTitlte")}`
|
||||||
}
|
}
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
setOpen={setConfirmOpen}
|
onClose={setConfirmOpen}
|
||||||
onConfirm={e =>
|
onConfirm={e =>
|
||||||
deletingContact
|
deletingContact
|
||||||
? handleDeleteContact(deletingContact.id)
|
? handleDeleteContact(deletingContact.id)
|
||||||
@@ -315,6 +319,10 @@ const Contacts = () => {
|
|||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<Can
|
||||||
|
role={user.profile}
|
||||||
|
perform="contacts-page:deleteContact"
|
||||||
|
yes={() => (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -324,6 +332,8 @@ const Contacts = () => {
|
|||||||
>
|
>
|
||||||
<DeleteOutlineIcon />
|
<DeleteOutlineIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const Login = () => {
|
|||||||
setUser({ ...user, [e.target.name]: e.target.value });
|
setUser({ ...user, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlSubmit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLogin(user);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container component="main" maxWidth="xs">
|
<Container component="main" maxWidth="xs">
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -71,11 +76,7 @@ const Login = () => {
|
|||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5">
|
||||||
{i18n.t("login.title")}
|
{i18n.t("login.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form
|
<form className={classes.form} noValidate onSubmit={handlSubmit}>
|
||||||
className={classes.form}
|
|
||||||
noValidate
|
|
||||||
onSubmit={e => handleLogin(e, user)}
|
|
||||||
>
|
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
margin="normal"
|
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(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
|
|
||||||
socket.on("settings", data => {
|
socket.on("settings", data => {
|
||||||
if (data.action === "update") {
|
if (data.action === "update") {
|
||||||
setSettings(prevState => {
|
setSettings(prevState => {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const Users = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||||
|
|
||||||
socket.on("user", data => {
|
socket.on("user", data => {
|
||||||
if (data.action === "update" || data.action === "create") {
|
if (data.action === "update" || data.action === "create") {
|
||||||
dispatch({ type: "UPDATE_USERS", payload: data.user });
|
dispatch({ type: "UPDATE_USERS", payload: data.user });
|
||||||
@@ -192,7 +193,7 @@ const Users = () => {
|
|||||||
}?`
|
}?`
|
||||||
}
|
}
|
||||||
open={confirmModalOpen}
|
open={confirmModalOpen}
|
||||||
setOpen={setConfirmModalOpen}
|
onClose={setConfirmModalOpen}
|
||||||
onConfirm={() => handleDeleteUser(deletingUser.id)}
|
onConfirm={() => handleDeleteUser(deletingUser.id)}
|
||||||
>
|
>
|
||||||
{i18n.t("users.confirmationModal.deleteMessage")}
|
{i18n.t("users.confirmationModal.deleteMessage")}
|
||||||
|
|||||||
@@ -7,19 +7,30 @@ import BackdropLoading from "../components/BackdropLoading";
|
|||||||
const RouteWrapper = ({ component: Component, isPrivate = false, ...rest }) => {
|
const RouteWrapper = ({ component: Component, isPrivate = false, ...rest }) => {
|
||||||
const { isAuth, loading } = useContext(AuthContext);
|
const { isAuth, loading } = useContext(AuthContext);
|
||||||
|
|
||||||
if (loading) return <BackdropLoading />;
|
|
||||||
|
|
||||||
if (!isAuth && isPrivate) {
|
if (!isAuth && isPrivate) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{loading && <BackdropLoading />}
|
||||||
<Redirect to={{ pathname: "/login", state: { from: rest.location } }} />
|
<Redirect to={{ pathname: "/login", state: { from: rest.location } }} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAuth && !isPrivate) {
|
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;
|
export default RouteWrapper;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { BrowserRouter, Switch } from "react-router-dom";
|
import { BrowserRouter, Switch } from "react-router-dom";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
import LoggedInLayout from "../components/_layout";
|
import LoggedInLayout from "../layout";
|
||||||
import Dashboard from "../pages/Dashboard/";
|
import Dashboard from "../pages/Dashboard/";
|
||||||
import Tickets from "../pages/Tickets/";
|
import Tickets from "../pages/Tickets/";
|
||||||
import Signup from "../pages/Signup/";
|
import Signup from "../pages/Signup/";
|
||||||
@@ -11,6 +11,7 @@ import Connections from "../pages/Connections/";
|
|||||||
import Settings from "../pages/Settings/";
|
import Settings from "../pages/Settings/";
|
||||||
import Users from "../pages/Users";
|
import Users from "../pages/Users";
|
||||||
import Contacts from "../pages/Contacts/";
|
import Contacts from "../pages/Contacts/";
|
||||||
|
import Queues from "../pages/Queues/";
|
||||||
import { AuthProvider } from "../context/Auth/AuthContext";
|
import { AuthProvider } from "../context/Auth/AuthContext";
|
||||||
import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext";
|
import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext";
|
||||||
import Route from "./Route";
|
import Route from "./Route";
|
||||||
@@ -40,6 +41,7 @@ const Routes = () => {
|
|||||||
<Route exact path="/contacts" component={Contacts} isPrivate />
|
<Route exact path="/contacts" component={Contacts} isPrivate />
|
||||||
<Route exact path="/users" component={Users} isPrivate />
|
<Route exact path="/users" component={Users} isPrivate />
|
||||||
<Route exact path="/Settings" component={Settings} isPrivate />
|
<Route exact path="/Settings" component={Settings} isPrivate />
|
||||||
|
<Route exact path="/Queues" component={Queues} isPrivate />
|
||||||
</LoggedInLayout>
|
</LoggedInLayout>
|
||||||
</WhatsAppsProvider>
|
</WhatsAppsProvider>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -153,6 +153,22 @@ const messages = {
|
|||||||
},
|
},
|
||||||
success: "Contact saved successfully.",
|
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: {
|
userModal: {
|
||||||
title: {
|
title: {
|
||||||
add: "Add user",
|
add: "Add user",
|
||||||
@@ -226,6 +242,7 @@ const messages = {
|
|||||||
connections: "Connections",
|
connections: "Connections",
|
||||||
tickets: "Tickets",
|
tickets: "Tickets",
|
||||||
contacts: "Contacts",
|
contacts: "Contacts",
|
||||||
|
queues: "Queues",
|
||||||
administration: "Administration",
|
administration: "Administration",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
@@ -240,6 +257,23 @@ const messages = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
noTickets: "No 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: {
|
users: {
|
||||||
title: "Users",
|
title: "Users",
|
||||||
table: {
|
table: {
|
||||||
|
|||||||
@@ -156,6 +156,22 @@ const messages = {
|
|||||||
},
|
},
|
||||||
success: "Contacto guardado satisfactoriamente.",
|
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: {
|
userModal: {
|
||||||
title: {
|
title: {
|
||||||
add: "Agregar usuario",
|
add: "Agregar usuario",
|
||||||
@@ -230,6 +246,7 @@ const messages = {
|
|||||||
connections: "Conexiones",
|
connections: "Conexiones",
|
||||||
tickets: "Tickets",
|
tickets: "Tickets",
|
||||||
contacts: "Contactos",
|
contacts: "Contactos",
|
||||||
|
queues: "Linhas",
|
||||||
administration: "Administración",
|
administration: "Administración",
|
||||||
users: "Usuarios",
|
users: "Usuarios",
|
||||||
settings: "Configuración",
|
settings: "Configuración",
|
||||||
@@ -244,6 +261,23 @@ const messages = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
noTickets: "Sin notificaciones.",
|
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: {
|
users: {
|
||||||
title: "Usuarios",
|
title: "Usuarios",
|
||||||
table: {
|
table: {
|
||||||
|
|||||||
@@ -154,6 +154,22 @@ const messages = {
|
|||||||
},
|
},
|
||||||
success: "Contato salvo com sucesso.",
|
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: {
|
userModal: {
|
||||||
title: {
|
title: {
|
||||||
add: "Adicionar usuário",
|
add: "Adicionar usuário",
|
||||||
@@ -228,6 +244,7 @@ const messages = {
|
|||||||
connections: "Conexões",
|
connections: "Conexões",
|
||||||
tickets: "Tickets",
|
tickets: "Tickets",
|
||||||
contacts: "Contatos",
|
contacts: "Contatos",
|
||||||
|
queues: "Filas",
|
||||||
administration: "Administração",
|
administration: "Administração",
|
||||||
users: "Usuários",
|
users: "Usuários",
|
||||||
settings: "Configurações",
|
settings: "Configurações",
|
||||||
@@ -242,6 +259,23 @@ const messages = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
noTickets: "Nenhuma notificação.",
|
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: {
|
users: {
|
||||||
title: "Usuários",
|
title: "Usuários",
|
||||||
table: {
|
table: {
|
||||||
|
|||||||
Reference in New Issue
Block a user