Feedbacks management go brr
This commit is contained in:
@@ -34,6 +34,7 @@ import ManageVoucherPage from "./pages/ManageVoucherPage";
|
||||
import CreateVoucherPage from "./pages/CreateVoucherPage";
|
||||
import EditVoucherPage from "./pages/EditVoucherPage";
|
||||
import FeedbackPage from "./pages/FeedbackPage";
|
||||
import ManageFeedbacksPage from "./pages/ManageFeedbacksPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -91,6 +92,7 @@ function App() {
|
||||
<Route path="/admin" element={<AdministratorLayout />}>
|
||||
<Route index element={<AdministratorSpringboard />} />
|
||||
<Route path="manage-account" element={<ManageUserAccountPage />} />
|
||||
<Route path="manage-feedbacks" element={<ManageFeedbacksPage />} />
|
||||
<Route path="users-management">
|
||||
<Route index element={<UsersManagement />} />
|
||||
</Route>
|
||||
|
||||
@@ -63,7 +63,8 @@ export default function AdministratorNavigationPanel() {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div
|
||||
className={`fixed transition-all ${isScrolled ? "top-2" : "top-10"
|
||||
className={`fixed transition-all ${
|
||||
isScrolled ? "top-2" : "top-10"
|
||||
} left-2`}
|
||||
>
|
||||
<div className="bg-white rounded-full z-40">
|
||||
@@ -85,14 +86,17 @@ export default function AdministratorNavigationPanel() {
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`h-full transition-all z-50 ${panelVisible
|
||||
className={`h-full transition-all z-50 ${
|
||||
panelVisible
|
||||
? "scale-100 opacity-100 w-[300px] px-2"
|
||||
: "w-0 scale-[98%] opacity-0 px-0"
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`fixed h-full transition-all z-50 ${isScrolled ? "pb-2 -mt-8" : "pb-10"
|
||||
} ${panelVisible
|
||||
className={`fixed h-full transition-all z-50 ${
|
||||
isScrolled ? "pb-2 -mt-8" : "pb-10"
|
||||
} ${
|
||||
panelVisible
|
||||
? "scale-100 opacity-100 w-[300px] p-2"
|
||||
: "w-0 scale-[98%] opacity-0 p-0"
|
||||
}`}
|
||||
@@ -196,7 +200,7 @@ export default function AdministratorNavigationPanel() {
|
||||
<AdministratorNavigationPanelNavigationButton
|
||||
text="Feedbacks"
|
||||
icon={<ChatBubbleOvalLeftIcon />}
|
||||
onClickRef="#"
|
||||
onClickRef="manage-feedbacks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -544,15 +544,17 @@ export const TrashDeleteIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-6">
|
||||
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -563,13 +565,13 @@ export const EmailIcon = () => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
@@ -582,13 +584,13 @@ export const InfoIcon = () => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -601,13 +603,13 @@ export const TrophyIcon = () => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0"
|
||||
/>
|
||||
</svg>
|
||||
@@ -651,3 +653,41 @@ export const ChevronDoubleDownIcon = () => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const CheckmarkIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m4.5 12.75 6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const BookOpenIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function FeedbackPage() {
|
||||
comment: Yup.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.max(2048)
|
||||
.required("Enter your comments."),
|
||||
allowContact: Yup.boolean().oneOf([true, false], "please decide"),
|
||||
});
|
||||
|
||||
246
client/src/pages/ManageFeedbacksPage.tsx
Normal file
246
client/src/pages/ManageFeedbacksPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import instance from "../security/http";
|
||||
import config from "../config";
|
||||
import { popErrorToast } from "../utilities";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
getKeyValue,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@nextui-org/react";
|
||||
import { BookOpenIcon, CheckmarkIcon, EmailIcon } from "../icons";
|
||||
import { retrieveUserInformationById } from "../security/usersbyid";
|
||||
|
||||
export default function ManageFeedbacksPage() {
|
||||
const [feedbacksList, setFeedbacksList] = useState<any>([]);
|
||||
const [viewFeedbackModalOpened, setViewFeedbackModalOpened] = useState(false);
|
||||
const [viewingFeedback, setViewingFeedback] = useState<any>();
|
||||
const columns = [
|
||||
{
|
||||
key: "feedbackCategory",
|
||||
label: "CATEGORY",
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "SUBJECT",
|
||||
},
|
||||
{
|
||||
key: "comment",
|
||||
label: "COMMENT",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "SUBMITTED",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "ACTIONS",
|
||||
},
|
||||
];
|
||||
|
||||
const populateFeedbacksList = () => {
|
||||
instance
|
||||
.get(`${config.serverAddress}/feedback/all`)
|
||||
.then((response) => {
|
||||
setFeedbacksList(response.data);
|
||||
console.log(feedbacksList);
|
||||
})
|
||||
.catch((error) => {
|
||||
popErrorToast(error);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
populateFeedbacksList();
|
||||
}, []);
|
||||
|
||||
const viewFeedback = (feedbackId: string) => {
|
||||
instance
|
||||
.get(`${config.serverAddress}/feedback/${feedbackId}`)
|
||||
.then((feedbackObject) => {
|
||||
setViewingFeedback(feedbackObject.data);
|
||||
setViewFeedbackModalOpened(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 p-8">
|
||||
<p className="text-4xl font-bold">Manage Feedbacks</p>
|
||||
<Table aria-label="User Information Table">
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => (
|
||||
<TableColumn key={column.key}>{column.label}</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody items={feedbacksList}>
|
||||
{(feedbackEntry: any) => (
|
||||
<TableRow key={feedbackEntry.id}>
|
||||
{(columnKey) => (
|
||||
<TableCell>
|
||||
{(() => {
|
||||
switch (columnKey) {
|
||||
case "feedbackCategory":
|
||||
let result = "";
|
||||
switch (getKeyValue(feedbackEntry, columnKey)) {
|
||||
case 0:
|
||||
result = "Feature request";
|
||||
break;
|
||||
case 1:
|
||||
result = "Bug report";
|
||||
break;
|
||||
case 2:
|
||||
result = "Get in touch";
|
||||
break;
|
||||
default:
|
||||
result = "Unknown";
|
||||
break;
|
||||
}
|
||||
return <Chip>{result}</Chip>;
|
||||
case "subject":
|
||||
return (
|
||||
<p className="w-max max-w-60 font-bold">
|
||||
{getKeyValue(feedbackEntry, columnKey)}
|
||||
</p>
|
||||
);
|
||||
case "comment":
|
||||
return (
|
||||
<div className="flex flex-row gap-4">
|
||||
<p className="flex-grow line-clamp-1 max-h-4 overflow-hidden overflow-ellipsis">
|
||||
{getKeyValue(feedbackEntry, columnKey)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "createdAt":
|
||||
let creationDate = Date.parse(
|
||||
getKeyValue(feedbackEntry, columnKey)
|
||||
);
|
||||
let timing = Math.floor(
|
||||
(Date.now() - creationDate) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
return (
|
||||
<p>
|
||||
{timing <= 0
|
||||
? "Today"
|
||||
: `${timing} day${timing == 1 ? "" : "s"} ago`}
|
||||
</p>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={
|
||||
<div className="scale-80">
|
||||
<BookOpenIcon />
|
||||
</div>
|
||||
}
|
||||
onPress={() => {
|
||||
viewFeedback(getKeyValue(feedbackEntry, "id"));
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="success"
|
||||
variant="faded"
|
||||
startContent={
|
||||
<div className="scale-80">
|
||||
<CheckmarkIcon />
|
||||
</div>
|
||||
}
|
||||
onPress={() => {
|
||||
instance.delete(
|
||||
`${
|
||||
config.serverAddress
|
||||
}/feedback/${getKeyValue(
|
||||
feedbackEntry,
|
||||
"id"
|
||||
)}`
|
||||
);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <p>{getKeyValue(feedbackEntry, columnKey)}</p>;
|
||||
}
|
||||
})()}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Modal
|
||||
isOpen={viewFeedbackModalOpened}
|
||||
onOpenChange={setViewFeedbackModalOpened}
|
||||
size="3xl"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
return (
|
||||
viewingFeedback && (
|
||||
<>
|
||||
<ModalHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>"{viewingFeedback.subject}"</p>
|
||||
<p className="font-normal text-sm opacity-50">
|
||||
Submitted on{" "}
|
||||
{new Date(viewingFeedback.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div>
|
||||
<Textarea
|
||||
label="Comment"
|
||||
size="lg"
|
||||
disabled
|
||||
value={viewingFeedback.comment}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className="flex flex-row gap-2">
|
||||
{viewingFeedback.allowContact == 1 && (
|
||||
<Button
|
||||
startContent={<EmailIcon />}
|
||||
onPress={() => {
|
||||
retrieveUserInformationById(
|
||||
viewingFeedback.userId
|
||||
).then((value) => {
|
||||
window.location.href = `mailto:${value.email}`;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Get in touch
|
||||
</Button>
|
||||
)}
|
||||
<Button onPress={onClose}>Close</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ module.exports = (sequelize) => {
|
||||
allowNull: false,
|
||||
},
|
||||
comment: {
|
||||
type: DataTypes.STRING(1024),
|
||||
type: DataTypes.STRING(2048),
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,14 +2,14 @@ const express = require("express");
|
||||
const router = express.Router();
|
||||
const yup = require("yup");
|
||||
const { validateToken } = require("../middlewares/auth");
|
||||
const {Feedback} = require("../models");
|
||||
const { Feedback } = require("../models");
|
||||
|
||||
let validationSchema = yup.object({
|
||||
userId: yup.string().trim().min(36).max(36).required(),
|
||||
feedbackCategory: yup.number().min(0).max(2).required(),
|
||||
allowContact: yup.boolean().required(),
|
||||
subject: yup.string().trim().min(1).max(100).required(),
|
||||
comment: yup.string().trim().min(1).max(100).required(),
|
||||
comment: yup.string().trim().min(1).max(2048).required(),
|
||||
});
|
||||
|
||||
router.get("/all", validateToken, async (req, res) => {
|
||||
@@ -56,4 +56,21 @@ router.post("/", validateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", validateToken, async (req, res) => {
|
||||
let id = req.params.id;
|
||||
|
||||
try {
|
||||
let result = await Feedback.destroy({ where: { id } });
|
||||
|
||||
if (result === 0) {
|
||||
res.sendStatus(404);
|
||||
} else {
|
||||
res.sendStatus(204);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error caught! Info: " + err);
|
||||
res.status(500).json({ error: "An error occurred while deleting the feedback." });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user