customer feedback

main
alex 2024-05-26 23:15:44 +02:00
parent bd4385e452
commit cdd2192274
13 changed files with 1343 additions and 181 deletions

939
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"i18next-browser-languagedetector": "^7.1.0", "i18next-browser-languagedetector": "^7.1.0",
"i18next-http-backend": "^2.2.1", "i18next-http-backend": "^2.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-countup": "^6.5.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-highlight-words": "^0.20.0", "react-highlight-words": "^0.20.0",
"react-i18next": "^13.0.1", "react-i18next": "^13.0.1",
@ -26,6 +27,7 @@
"react-stl-viewer": "^2.2.5", "react-stl-viewer": "^2.2.5",
"react-virtuoso": "^4.5.1", "react-virtuoso": "^4.5.1",
"react-webcam": "^7.1.1", "react-webcam": "^7.1.1",
"recharts": "^2.12.7",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },

View File

@ -55,6 +55,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"equipmentDocumentation": "Gerätedokumentation", "equipmentDocumentation": "Gerätedokumentation",
"consoles": "Konsolen", "consoles": "Konsolen",
"customerFeedback": "Bewertungen",
"groupTasks": { "groupTasks": {
"menuCategory": "Gruppenaufgaben", "menuCategory": "Gruppenaufgaben",
"overview": "Kategorien", "overview": "Kategorien",
@ -705,5 +706,12 @@
"noLogTypesFound": "Keine Log-Typen gefunden", "noLogTypesFound": "Keine Log-Typen gefunden",
"noLogManagerServerSpecified": "Kein Log-Manager-Server angegeben", "noLogManagerServerSpecified": "Kein Log-Manager-Server angegeben",
"couldntReachlogManagerServer": "Verbindung zum Log-Manager-Server konnte nicht hergestellt werden" "couldntReachlogManagerServer": "Verbindung zum Log-Manager-Server konnte nicht hergestellt werden"
},
"customerFeedback": {
"origin": "Herkunft:",
"feedbacks": "Bewertungen",
"table": {
"createdAt":"Erstellt am"
}
} }
} }

View File

@ -55,6 +55,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"equipmentDocumentation": "Equipment Documentation", "equipmentDocumentation": "Equipment Documentation",
"consoles": "Consoles", "consoles": "Consoles",
"customerFeedback": "Feedbacks",
"groupTasks": { "groupTasks": {
"menuCategory": "Group Tasks", "menuCategory": "Group Tasks",
"overview": "Categories", "overview": "Categories",
@ -709,5 +710,12 @@
"noLogTypesFound": "No log types found", "noLogTypesFound": "No log types found",
"noLogManagerServerSpecified": "No log manager specified", "noLogManagerServerSpecified": "No log manager specified",
"couldntReachlogManagerServer": "Connection to log manager server failed" "couldntReachlogManagerServer": "Connection to log manager server failed"
},
"customerFeedback": {
"origin": "Origin:",
"feedbacks": "Feedbacks",
"table": {
"createdAt":"Created at"
}
} }
} }

View File

@ -16,6 +16,7 @@ import HeaderProvider from "./Contexts/HeaderContext";
import ConsolesProvider from "./Contexts/ConsolesContext"; import ConsolesProvider from "./Contexts/ConsolesContext";
import ScannerProvider from "./Contexts/ScannerContext"; import ScannerProvider from "./Contexts/ScannerContext";
import { CrmProvider } from "./Contexts/CrmContext"; import { CrmProvider } from "./Contexts/CrmContext";
import { CustomerFeedbackProvider } from "./Contexts/CustomerFeedbackContext";
export default function App() { export default function App() {
const [notificationApi, notificationContextHolder] = const [notificationApi, notificationContextHolder] =
@ -49,22 +50,24 @@ export default function App() {
<ConsolesProvider> <ConsolesProvider>
<ScannerProvider> <ScannerProvider>
<CrmProvider> <CrmProvider>
<WebSocketProvider <CustomerFeedbackProvider>
userSession={userSession} <WebSocketProvider
setUserSession={setUserSession}
isWebSocketReady={isWebSocketReady}
setIsWebSocketReady={setIsWebSocketReady}
notificationApi={notificationApi}
>
<ReconnectingView
isWebSocketReady={isWebSocketReady}
/>
<DashboardLayout
userSession={userSession} userSession={userSession}
setUserSession={setUserSession} setUserSession={setUserSession}
/> isWebSocketReady={isWebSocketReady}
</WebSocketProvider> setIsWebSocketReady={setIsWebSocketReady}
notificationApi={notificationApi}
>
<ReconnectingView
isWebSocketReady={isWebSocketReady}
/>
<DashboardLayout
userSession={userSession}
setUserSession={setUserSession}
/>
</WebSocketProvider>
</CustomerFeedbackProvider>
</CrmProvider> </CrmProvider>
</ScannerProvider> </ScannerProvider>
</ConsolesProvider> </ConsolesProvider>

View File

@ -23,8 +23,9 @@ const ViewEquipmentDocumentations = lazy(() =>
); );
const Consoles = lazy(() => import("../../Pages/Consoles")); const Consoles = lazy(() => import("../../Pages/Consoles"));
const RoboticsRobots = lazy(() => import("../../Pages/Robotics/Robots")); const RoboticsRobots = lazy(() => import("../../Pages/Robotics/Robots"));
const Crm = lazy(() => import("../../Pages/Crm")); // const Crm = lazy(() => import("../../Pages/Crm"));
const CrmTest = lazy(() => import("../../Pages/CrmTest/CrmTest")); const CrmTest = lazy(() => import("../../Pages/CrmTest/CrmTest"));
const CustomerFeedback = lazy(() => import("../../Pages/CustomerFeedback"));
export default function AppRoutes({ userSession, setUserSession }) { export default function AppRoutes({ userSession, setUserSession }) {
const appContext = useAppContext(); const appContext = useAppContext();
@ -258,6 +259,34 @@ export default function AppRoutes({ userSession, setUserSession }) {
/> />
)} )}
{hasPermission(
appContext.userPermissions,
Constants.PERMISSIONS.CUSTOMERFEEDBACK.VIEW
) && (
<Route
path={Constants.ROUTE_PATHS.CUSTOMERFEEDBACK_VIEW}
element={
<MySupsenseFallback>
<CustomerFeedback />
</MySupsenseFallback>
}
/>
)}
{hasPermission(
appContext.userPermissions,
Constants.PERMISSIONS.CUSTOMERFEEDBACK.VIEW
) && (
<Route
path={`${Constants.ROUTE_PATHS.CUSTOMERFEEDBACK_VIEW}/:paramOrigin`}
element={
<MySupsenseFallback>
<CustomerFeedback />
</MySupsenseFallback>
}
/>
)}
<Route <Route
path="*" path="*"
element={ element={

View File

@ -0,0 +1,7 @@
export const RequestState = {
INIT: -1,
NOTHING: 0,
REQUESTING: 1,
SUCCESS: 2,
FAILED: 3,
};

View File

@ -82,6 +82,20 @@ export function SideMenuContent({
}); });
} }
// customer feedback
if (
hasPermission(
appContext.userPermissions,
Constants.PERMISSIONS.CUSTOMERFEEDBACK.VIEW
)
) {
items.push({
label: t("sideMenu.customerFeedback"),
icon: <SnippetsOutlined />,
key: Constants.ROUTE_PATHS.CUSTOMERFEEDBACK_VIEW,
});
}
// group tasks // group tasks
let groupTasksGroup = { let groupTasksGroup = {
label: t("sideMenu.groupTasks.menuCategory"), label: t("sideMenu.groupTasks.menuCategory"),
@ -149,6 +163,7 @@ export function SideMenuContent({
label: t("sideMenu.robotics.robots"), label: t("sideMenu.robotics.robots"),
icon: <RobotOutlined />, icon: <RobotOutlined />,
key: Constants.ROUTE_PATHS.ROBOTICS_ROBOTS, key: Constants.ROUTE_PATHS.ROBOTICS_ROBOTS,
disabled: true,
}); });
items.push(roboticsGroup); items.push(roboticsGroup);

View File

@ -0,0 +1,31 @@
import { createContext, useContext, useState } from "react";
const preview = {
origins: [],
setOrigins: () => {},
customerFeedbacks: [],
setCustomerFeedbacks: () => {},
};
const CustomerFeedbackContext = createContext(preview);
export const useCustomerFeedbackContext = () =>
useContext(CustomerFeedbackContext);
export function CustomerFeedbackProvider({ children }) {
const [origins, setOrigins] = useState([]);
const [customerFeedbacks, setCustomerFeedbacks] = useState([]);
return (
<CustomerFeedbackContext.Provider
value={{
origins,
setOrigins,
customerFeedbacks,
setCustomerFeedbacks,
}}
>
{children}
</CustomerFeedbackContext.Provider>
);
}

View File

@ -17,6 +17,7 @@ import { useHeaderContext } from "./HeaderContext";
import { useConsolesContext } from "./ConsolesContext"; import { useConsolesContext } from "./ConsolesContext";
import { useScannerContext } from "./ScannerContext"; import { useScannerContext } from "./ScannerContext";
import { useCrmContext } from "./CrmContext"; import { useCrmContext } from "./CrmContext";
import { useCustomerFeedbackContext } from "./CustomerFeedbackContext";
const WebSocketContext = createContext(null); const WebSocketContext = createContext(null);
@ -46,6 +47,7 @@ export default function WebSocketProvider({
const consolesContext = useConsolesContext(); const consolesContext = useConsolesContext();
const scannerContext = useScannerContext(); const scannerContext = useScannerContext();
const crmContext = useCrmContext(); const crmContext = useCrmContext();
const customerFeedbackContext = useCustomerFeedbackContext();
if (wsConnectionEvent === null) { if (wsConnectionEvent === null) {
wsConnectionEvent = new CustomEvent(wsConnectionCustomEventName, { wsConnectionEvent = new CustomEvent(wsConnectionCustomEventName, {
@ -106,7 +108,8 @@ export default function WebSocketProvider({
usersContext, usersContext,
consolesContext, consolesContext,
scannerContext, scannerContext,
crmContext crmContext,
customerFeedbackContext
); );
}; };

View File

@ -55,6 +55,7 @@ export const ReceivedMessagesCommands = {
CrmLinkCreated: 51, CrmLinkCreated: 51,
CrmLinkUsed: 52, CrmLinkUsed: 52,
CrmLinkDeleted: 53, CrmLinkDeleted: 53,
CustomerFeedbackAddFeedback: 54,
}; };
// commands sent to the backend server // commands sent to the backend server
@ -100,7 +101,8 @@ export function handleWebSocketMessage(
usersContext, usersContext,
consolesContext, consolesContext,
scannerContext, scannerContext,
crmContext crmContext,
customerFeedbackContext
) { ) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@ -1120,6 +1122,9 @@ export function handleWebSocketMessage(
arr.filter((link) => link.Id !== body) arr.filter((link) => link.Id !== body)
); );
break; break;
case ReceivedMessagesCommands.CustomerFeedbackAddFeedback:
customerFeedbackContext.setCustomerFeedbacks((arr) => [...arr, body]);
break;
default: default:
console.error("unknown command", cmd); console.error("unknown command", cmd);
break; break;

View File

@ -0,0 +1,436 @@
import { useEffect, useState } from "react";
import {
Constants,
FormatDatetime,
myFetch,
wsConnectionCustomEventName,
} from "../../utils";
import { useCustomerFeedbackContext } from "../../Contexts/CustomerFeedbackContext";
import { Card, Col, Result, Row, Select, Spin, Statistic, Table } from "antd";
import { useTranslation } from "react-i18next";
import { RequestState } from "../../Components/MyRequestState";
import { useNavigate, useParams } from "react-router-dom";
import CountUp from "react-countup";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
function calculateAverage(arr) {
if (arr.length === 0) return 0;
const sum = arr.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0
);
const average = sum / arr.length;
return average.toFixed(1);
}
export default function CustomerFeedback() {
const { t } = useTranslation();
const navigate = useNavigate();
const customerFeedbackContext = useCustomerFeedbackContext();
const { paramOrigin } = useParams();
const [isRequesting, setIsRequesting] = useState(RequestState.INIT);
const [selectedOrigin, setSelectedOrigin] = useState("");
const getTableContent = () => {
const feedbackColumns = [];
const columns = [];
for (const feedback of customerFeedbackContext.customerFeedbacks) {
if (feedback.Data === null) continue;
for (const key in feedback.Data) {
if (!columns.includes(key)) {
feedbackColumns.push({
title: key,
dataIndex: key,
key: key,
});
columns.push(key);
}
}
}
return [
...feedbackColumns,
{
title: t("customerFeedback.table.createdAt"),
dataIndex: "createdAt",
key: "createdAt",
},
];
};
const getTableItems = () => {
const items = [];
customerFeedbackContext.customerFeedbacks.sort(
(a, b) => new Date(b.CreatedAt) - new Date(a.CreatedAt)
);
customerFeedbackContext.customerFeedbacks.forEach((customerFeedback) => {
let dynamicItems = {};
if (customerFeedback.Data !== null) {
for (const key in customerFeedback.Data) {
dynamicItems[key] = customerFeedback.Data[key];
}
}
items.push({
key: customerFeedback.Id,
...dynamicItems,
createdAt: FormatDatetime(customerFeedback.CreatedAt),
});
});
return items;
};
const navigateToOrigin = (origin) =>
navigate(`${Constants.ROUTE_PATHS.CUSTOMERFEEDBACK_VIEW}/${origin}`);
const StatisticCard = () => {
const data = {};
for (const customerFeedback of customerFeedbackContext.customerFeedbacks) {
if (customerFeedback.Data === null) continue;
for (const feedbackDataKey in customerFeedback.Data) {
if (typeof customerFeedback.Data[feedbackDataKey] !== "number")
continue;
if (!data.hasOwnProperty(feedbackDataKey)) {
data[feedbackDataKey] = [];
}
data[feedbackDataKey].push(customerFeedback.Data[feedbackDataKey]);
}
}
const elements = [
<Statistic
title={t("customerFeedback.feedbacks")}
value={customerFeedbackContext.customerFeedbacks.length}
formatter={(value) => <CountUp end={value} />}
/>,
];
for (const statistic in data) {
elements.push(
<Statistic
key={statistic}
title={statistic}
value={calculateAverage(data[statistic])}
formatter={(value) => (
<span>
<CountUp end={value} prefix="Ø " decimals={1} /> / {" "}
{data[statistic].length}
</span>
)}
/>
);
}
const chunkedElements = elements.reduce((acc, element, index) => {
const chunkIndex = Math.floor(index / 8);
if (!acc[chunkIndex]) {
acc[chunkIndex] = [];
}
acc[chunkIndex].push(element);
return acc;
}, []);
return (
<Card style={{ marginTop: 20 }}>
{chunkedElements.map((chunk, chunkIndex) => (
<Row gutter={16} key={chunkIndex}>
{chunk.map((element, elementIndex) => (
<Col xs={24} sm={12} md={4} key={elementIndex}>
{element}
</Col>
))}
</Row>
))}
</Card>
);
};
const Graphs = () => {
if (
!customerFeedbackContext.customerFeedbacks ||
customerFeedbackContext.customerFeedbacks.length === 0
) {
return <div>No data available</div>;
}
// Function to count number of feedbacks for each date
const countFeedbacksByDate = (feedbacks) => {
const dateCounts = {};
// Iterate through feedbacks
feedbacks.forEach((feedback) => {
const date = new Date(feedback.CreatedAt).toISOString().split("T")[0];
// Increment count for date or initialize it with 1
dateCounts[date] = (dateCounts[date] || 0) + 1;
});
// Convert date-counts map to array of objects and sort by date ascending
return Object.keys(dateCounts)
.map((date) => ({
date,
count: dateCounts[date],
}))
.sort((a, b) => new Date(a.date) - new Date(b.date));
};
const data = countFeedbacksByDate(
customerFeedbackContext.customerFeedbacks
);
// Function to extract data for each key in Data
const getDataByKey = (feedbacks, key) => {
const dateValuesMap = {};
// Iterate through feedbacks
feedbacks.forEach((feedback) => {
if (feedback.Data && typeof feedback.Data === "object") {
const date = new Date(feedback.CreatedAt).toISOString().split("T")[0];
const value = feedback.Data[key];
// Increment count for date or initialize it with value
dateValuesMap[date] =
(dateValuesMap[date] || 0) + (typeof value === "number" ? 1 : 0);
}
});
// Convert date-values map to array of objects and sort by date ascending
return Object.keys(dateValuesMap)
.map((date) => ({
date,
value: dateValuesMap[date],
}))
.sort((a, b) => new Date(a.date) - new Date(b.date));
};
return (
<div>
<Card style={{ marginTop: 20 }}>
<h2>Feedback Count</h2>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="count"
stroke="#8884d8"
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
</div>
);
/*
{Object.keys(customerFeedbackContext.customerFeedbacks[0]?.Data || {})
.filter(
(key) =>
typeof customerFeedbackContext.customerFeedbacks[0]?.Data[key] ===
"number"
)
.map((key) => (
<Card key={key} style={{ marginTop: 20 }}>
<h2>{key}</h2>
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={getDataByKey(
customerFeedbackContext.customerFeedbacks,
key
)}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="value"
stroke="#8884d8"
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
))}
*/
};
// Function to group data by date and count occurrences
/*const getDataByDate = (feedbacks) => {
const dateCounts = feedbacks.reduce((acc, feedback) => {
const date = new Date(feedback.CreatedAt).toISOString().split("T")[0]; // Extract date part
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {});
const sortedDates = Object.keys(dateCounts).sort(
(a, b) => new Date(a) - new Date(b)
);
return sortedDates.map((date) => ({
date,
count: dateCounts[date],
}));
};
const data = getDataByDate(customerFeedbackContext.customerFeedbacks);
return (
<Card style={{ marginTop: 20 }}>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="count"
stroke="#8884d8"
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
);
}; */
useEffect(() => {
const customersRequest = () => {
setIsRequesting(RequestState.REQUESTING);
myFetch(`/customerfeedback/origins`, "GET").then((data) => {
if (data === undefined || data === null) return;
customerFeedbackContext.setOrigins(data);
if (data.length === 0) {
setIsRequesting(RequestState.NOTHING);
} else if (paramOrigin === undefined || !data.includes(paramOrigin)) {
navigateToOrigin(data[0]);
}
});
};
customersRequest();
const handleCustomersRequest = () => customersRequest();
document.addEventListener(
wsConnectionCustomEventName,
handleCustomersRequest
);
return () =>
document.removeEventListener(
wsConnectionCustomEventName,
handleCustomersRequest
);
}, []);
useEffect(() => {
if (paramOrigin === undefined) return;
setIsRequesting(RequestState.REQUESTING);
setSelectedOrigin(paramOrigin);
myFetch(`/customerfeedback/origin/${paramOrigin}`, "GET").then((data) => {
if (data === undefined || data === null) return;
customerFeedbackContext.setCustomerFeedbacks(data);
setIsRequesting(RequestState.SUCCESS);
});
}, [paramOrigin]);
return (
<>
<div
style={{
display: "flex",
flexDirection: "row",
gap: 10,
alignItems: "center",
}}
>
<span>{t("customerFeedback.origin")}</span>
<Select
style={{
width: "100%",
}}
value={selectedOrigin}
onSelect={(value) => navigateToOrigin(value)}
options={customerFeedbackContext.origins.map((origin) => {
return {
value: origin,
label: origin,
};
})}
></Select>
</div>
{isRequesting === RequestState.INIT ? (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "80vh",
}}
>
<Spin size="large"></Spin>
</div>
) : isRequesting === RequestState.NOTHING ? (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
/>
) : (
<>
<StatisticCard />
<Graphs />
<Table
style={{ paddingTop: 20 }}
scroll={{ x: "max-content" }}
columns={getTableContent()}
dataSource={getTableItems()}
loading={isRequesting === RequestState.REQUESTING}
pagination
/>
</>
)}
</>
);
}

View File

@ -97,6 +97,7 @@ export const Constants = {
ROBOTICS_ROBOTS: "/robotics/robots", ROBOTICS_ROBOTS: "/robotics/robots",
CRM: "/crm/", CRM: "/crm/",
CRM_TEST: "/crm/test", CRM_TEST: "/crm/test",
CUSTOMERFEEDBACK_VIEW: "/customer-feedback",
}, },
CRM_TYPE: { CRM_TYPE: {
TEST_CUSTOMERS: "test-customers", TEST_CUSTOMERS: "test-customers",
@ -224,6 +225,9 @@ export const Constants = {
VIEW: "crm.setter_closer.view", VIEW: "crm.setter_closer.view",
}, },
}, },
CUSTOMERFEEDBACK: {
VIEW: "customerfeedback.view",
},
}, },
SYSTEM_LOG_TYPE: { SYSTEM_LOG_TYPE: {
INFO: 0, INFO: 0,