Skip to content

Commit ae9ac92

Browse files
committed
feat(Support): assign tickets
1 parent 9162e38 commit ae9ac92

File tree

9 files changed

+256
-19
lines changed

9 files changed

+256
-19
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useEffect, useState } from "react";
2+
import {
3+
Modal,
4+
Button,
5+
ModalProps,
6+
Dropdown,
7+
Form,
8+
Icon,
9+
} from "semantic-ui-react";
10+
import useGlobalError from "../error/ErrorHooks";
11+
import { User } from "../../types";
12+
import axios from "axios";
13+
14+
interface AssignTicketModalProps extends ModalProps {
15+
open: boolean;
16+
onClose: () => void;
17+
ticketId: string;
18+
}
19+
20+
const AssignTicketModal: React.FC<AssignTicketModalProps> = ({
21+
open,
22+
onClose,
23+
ticketId,
24+
...rest
25+
}) => {
26+
const { handleGlobalError } = useGlobalError();
27+
const [loading, setLoading] = useState(false);
28+
const [users, setUsers] = useState<
29+
Pick<User, "uuid" | "firstName" | "lastName" | "email">[]
30+
>([]);
31+
const [usersToAssign, setUsersToAssign] = useState<string[]>([]);
32+
33+
useEffect(() => {
34+
if (open) {
35+
getAssignableUsers();
36+
}
37+
}, [open]);
38+
39+
async function getAssignableUsers() {
40+
try {
41+
if (!ticketId) return;
42+
setLoading(true);
43+
44+
const res = await axios.get(`/support/ticket/${ticketId}/assign`);
45+
46+
if (res.data.err) {
47+
throw new Error(res.data.errMsg);
48+
}
49+
50+
if (!res.data.users) {
51+
throw new Error("Invalid response from server");
52+
}
53+
54+
setUsers(res.data.users);
55+
} catch (err) {
56+
handleGlobalError(err);
57+
} finally {
58+
setLoading(false);
59+
}
60+
}
61+
62+
async function assignTicket() {
63+
try {
64+
setLoading(true);
65+
if (!ticketId) return;
66+
if (!usersToAssign || !usersToAssign.length) {
67+
throw new Error("No users selected");
68+
}
69+
70+
const res = await axios.patch(`/support/ticket/${ticketId}/assign`, {
71+
assigned: usersToAssign,
72+
});
73+
74+
if (res.data.err) {
75+
throw new Error(res.data.errMsg);
76+
}
77+
78+
onClose();
79+
} catch (err) {
80+
handleGlobalError(err);
81+
} finally {
82+
setLoading(false);
83+
}
84+
}
85+
86+
return (
87+
<Modal open={open} onClose={onClose} {...rest}>
88+
<Modal.Header>Assign Ticket to User(s)</Modal.Header>
89+
<Modal.Content>
90+
<Form onSubmit={(e) => e.preventDefault()}>
91+
<Dropdown
92+
id="selectUsers"
93+
options={users.map((u) => ({
94+
key: u.uuid,
95+
value: u.uuid,
96+
text: `${u.firstName} ${u.lastName} (${u.email})`,
97+
}))}
98+
onChange={(e, { value }) => {
99+
setUsersToAssign(value as string[]);
100+
}}
101+
fluid
102+
selection
103+
multiple
104+
search
105+
placeholder="Select User(s)"
106+
/>
107+
</Form>
108+
</Modal.Content>
109+
<Modal.Actions>
110+
<Button onClick={onClose} loading={loading}>
111+
Cancel
112+
</Button>
113+
<Button
114+
onClick={assignTicket}
115+
positive
116+
disabled={usersToAssign.length === 0}
117+
loading={loading}
118+
>
119+
<Icon name="user plus" />
120+
Assign
121+
</Button>
122+
</Modal.Actions>
123+
</Modal>
124+
);
125+
};
126+
127+
export default AssignTicketModal;

client/src/components/support/StaffDashboard.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ const StaffDashboard = () => {
5959

6060
setTotalItems(res.data.total);
6161
setTotalPages(Math.ceil(res.data.total / items));
62-
return res.data.tickets;
62+
return (res.data.tickets as SupportTicket[]) ?? [];
6363
} catch (err) {
6464
handleGlobalError(err);
65+
return [];
6566
} finally {
6667
setLoading(false);
6768
}
@@ -166,8 +167,8 @@ const StaffDashboard = () => {
166167
<Table.Cell>{ticket.title}</Table.Cell>
167168
<Table.Cell>{getRequesterText(ticket)}</Table.Cell>
168169
<Table.Cell>
169-
{ticket.assignedTo
170-
? ticket.assignedTo.firstName.toString()
170+
{ticket.assignedUsers
171+
? ticket.assignedUsers.map((u) => u.firstName).join(", ")
171172
: "Unassigned"}
172173
</Table.Cell>
173174
<Table.Cell>

client/src/components/support/TicketMessaging.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const TicketMessaging: React.FC<TicketMessagingProps> = ({ id }) => {
3636
refetchOnWindowFocus: true,
3737
});
3838

39-
async function getMessages() {
39+
async function getMessages(): Promise<SupportTicketMessage[]> {
4040
try {
4141
if (!id) throw new Error("Invalid ticket ID");
4242
const res = await axios.get(`/support/ticket/${id}/msg/staff`);
@@ -52,9 +52,10 @@ const TicketMessaging: React.FC<TicketMessagingProps> = ({ id }) => {
5252
return new Date(a.timeSent).getTime() - new Date(b.timeSent).getTime();
5353
});
5454

55-
return msgs;
55+
return (msgs as SupportTicketMessage[]) ?? [];
5656
} catch (err) {
5757
handleGlobalError(err);
58+
return [];
5859
}
5960
}
6061

@@ -113,7 +114,7 @@ const TicketMessaging: React.FC<TicketMessagingProps> = ({ id }) => {
113114
return (
114115
<div className="flex flex-col w-full">
115116
<div className="flex flex-col border shadow-md rounded-md p-4">
116-
<p className="text-2xl font-semibold text-center">Ticket History</p>
117+
<p className="text-2xl font-semibold text-center">Ticket Chat</p>
117118
<div className="flex flex-col mt-8">
118119
{messages?.length === 0 && (
119120
<p className="text-lg text-center text-gray-500 italic">
@@ -137,7 +138,10 @@ const TicketMessaging: React.FC<TicketMessagingProps> = ({ id }) => {
137138
<Icon name="trash" />
138139
Clear
139140
</Button>
140-
<Button color="blue" onClick={() => sendMessageMutation.mutateAsync()}>
141+
<Button
142+
color="blue"
143+
onClick={() => sendMessageMutation.mutateAsync()}
144+
>
141145
<Icon name="send" />
142146
Send
143147
</Button>

client/src/screens/conductor/support/Ticket.tsx

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, lazy } from "react";
22
import { useParams } from "react-router-dom";
33
import useGlobalError from "../../../components/error/ErrorHooks";
44
import DefaultLayout from "../../../components/kb/DefaultLayout";
@@ -8,7 +8,10 @@ import { format, parseISO } from "date-fns";
88
import TicketStatusLabel from "../../../components/support/TicketStatusLabel";
99
import TicketMessaging from "../../../components/support/TicketMessaging";
1010
import { useTypedSelector } from "../../../state/hooks";
11-
import { Button, Icon } from "semantic-ui-react";
11+
import { Button, Icon, Label } from "semantic-ui-react";
12+
const AssignTicketModal = lazy(
13+
() => import("../../../components/support/AssignTicketModal")
14+
);
1215

1316
const SupportTicketView = () => {
1417
const { handleGlobalError } = useGlobalError();
@@ -17,6 +20,7 @@ const SupportTicketView = () => {
1720

1821
const [loading, setLoading] = useState(false);
1922
const [ticket, setTicket] = useState<SupportTicket | null>(null);
23+
const [showAssignModal, setShowAssignModal] = useState(false);
2024

2125
useEffect(() => {
2226
document.title = "LibreTexts | Support Ticket";
@@ -70,7 +74,7 @@ const SupportTicketView = () => {
7074

7175
const AdminOptions = () => (
7276
<div className="flex flex-row">
73-
<Button color="blue">
77+
<Button color="blue" onClick={() => setShowAssignModal(true)}>
7478
<Icon name="user plus" />
7579
Assign Ticket
7680
</Button>
@@ -99,7 +103,17 @@ const SupportTicketView = () => {
99103
<div className="flex flex-col basis-1/2">
100104
<p className="text-xl">
101105
<span className="font-semibold">Requester:</span>{" "}
102-
{ticket?.title}
106+
{ticket.user && (
107+
<>
108+
<span>
109+
`${ticket.user.firstName} ${ticket.user.lastName} ($
110+
{ticket.user.email})`
111+
</span>
112+
<Label>Authenticated</Label>
113+
</>
114+
)}
115+
{ticket.guest &&
116+
`${ticket.guest.firstName} ${ticket.guest.lastName} (${ticket.guest.email})`}
103117
</p>
104118
<p className="text-xl">
105119
<span className="font-semibold">Subject:</span>{" "}
@@ -133,6 +147,11 @@ const SupportTicketView = () => {
133147
</>
134148
)}
135149
</div>
150+
<AssignTicketModal
151+
open={showAssignModal}
152+
onClose={() => setShowAssignModal(false)}
153+
ticketId={id}
154+
/>
136155
</DefaultLayout>
137156
);
138157
};

client/src/types/support.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type SupportTicket = {
1717
status: "open" | "in_progress" | "closed";
1818
category: string;
1919
capturedURL?: string;
20-
assignedTo?: User;
20+
assignedUsers?: User[];
2121
user?: User;
2222
guest?: SupportTicketGuest;
2323
timeOpened: string;

server/api.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,7 @@ router.route('/kb/featured/video/:uuid').delete(
15511551
router.route('/support/metrics').get(
15521552
authAPI.verifyRequest,
15531553
authAPI.getUserAttributes,
1554-
authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'),
1554+
authAPI.checkHasRoleMiddleware('libretexts', 'support'),
15551555
supportAPI.getSupportMetrics
15561556
)
15571557

@@ -1570,6 +1570,19 @@ router.route('/support/ticket/user').get(
15701570
supportAPI.getUserTickets
15711571
)
15721572

1573+
router.route('/support/ticket/:uuid/assign').get(
1574+
authAPI.verifyRequest,
1575+
authAPI.getUserAttributes,
1576+
authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'),
1577+
supportAPI.getAssignableUsers
1578+
).patch(
1579+
authAPI.verifyRequest,
1580+
authAPI.getUserAttributes,
1581+
authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'),
1582+
middleware.validateZod(supportValidators.AssignTicketValidator),
1583+
supportAPI.assignTicket
1584+
)
1585+
15731586
router.route('/support/ticket/:uuid/msg/staff').post(
15741587
authAPI.verifyRequest,
15751588
authAPI.getUserAttributes,

0 commit comments

Comments
 (0)