mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-21 14:48:43 +00:00
471 lines
19 KiB
TypeScript
471 lines
19 KiB
TypeScript
/*
|
|
* QQQ - Low-code Application Framework for Engineers.
|
|
* Copyright (C) 2021-2023. Kingsrook, LLC
|
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
* contact@kingsrook.com
|
|
* https://github.com/Kingsrook/
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
|
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
|
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
|
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
|
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
|
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
|
|
import Avatar from "@mui/material/Avatar";
|
|
import Box from "@mui/material/Box";
|
|
import Icon from "@mui/material/Icon/Icon";
|
|
import ToggleButton from "@mui/material/ToggleButton";
|
|
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
|
import Tooltip from "@mui/material/Tooltip";
|
|
import Typography from "@mui/material/Typography";
|
|
import QContext from "QContext";
|
|
import Client from "qqq/utils/qqq/Client";
|
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
|
import React, {useContext, useEffect, useState} from "react";
|
|
|
|
interface Props
|
|
{
|
|
tableMetaData: QTableMetaData;
|
|
recordId: any;
|
|
record: QRecord;
|
|
}
|
|
|
|
AuditBody.defaultProps =
|
|
{};
|
|
|
|
const qController = Client.getInstance();
|
|
|
|
function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
|
{
|
|
const [audits, setAudits] = useState([] as QRecord[]);
|
|
const [total, setTotal] = useState(null as number);
|
|
const [limit, setLimit] = useState(1000);
|
|
const [statusString, setStatusString] = useState("Loading audits...");
|
|
const [auditsByDate, setAuditsByDate] = useState([] as QRecord[][]);
|
|
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>);
|
|
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>);
|
|
const [sortDirection, setSortDirection] = useState(localStorage.getItem("audit.sortDirection") === "true");
|
|
const {accentColor} = useContext(QContext);
|
|
|
|
function wrapValue(value: any): JSX.Element
|
|
{
|
|
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>;
|
|
}
|
|
|
|
function wasValue(value: any): JSX.Element
|
|
{
|
|
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>;
|
|
}
|
|
|
|
function getAuditDetailFieldChangeRow(qRecord: QRecord): JSX.Element | null
|
|
{
|
|
const message = qRecord.values.get("auditDetail.message");
|
|
const fieldName = qRecord.values.get("auditDetail.fieldName");
|
|
const oldValue = qRecord.values.get("auditDetail.oldValue");
|
|
const newValue = qRecord.values.get("auditDetail.newValue");
|
|
if (fieldName && (oldValue !== null || newValue !== null))
|
|
{
|
|
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
|
|
return (<tr>
|
|
<td>{fieldLabel}</td>
|
|
<td>{oldValue}</td>
|
|
<td>{newValue}</td>
|
|
</tr>);
|
|
}
|
|
return (null);
|
|
}
|
|
|
|
function getAuditDetailElement(qRecord: QRecord): JSX.Element | null
|
|
{
|
|
const message = qRecord.values.get("auditDetail.message");
|
|
const fieldName = qRecord.values.get("auditDetail.fieldName");
|
|
const oldValue = qRecord.values.get("auditDetail.oldValue");
|
|
const newValue = qRecord.values.get("auditDetail.newValue");
|
|
if (fieldName && (oldValue !== null || newValue !== null))
|
|
{
|
|
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
|
|
if (oldValue !== undefined && newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel}: Changed from {(oldValue)} to <b>{(newValue)}</b></>);
|
|
}
|
|
else if (newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel}: Set to <b>{(newValue)}</b></>);
|
|
}
|
|
else if (oldValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
|
|
}
|
|
else if (message)
|
|
{
|
|
return (<>{message}</>);
|
|
}
|
|
|
|
/*
|
|
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}</span>;
|
|
if(oldValue !== undefined && newValue !== undefined)
|
|
{
|
|
return (<>Changed {fieldLabel} from {wrapValue(oldValue)} to {wrapValue(newValue)}</>);
|
|
}
|
|
else if(newValue !== undefined)
|
|
{
|
|
return (<>Set {fieldLabel} to {wrapValue(newValue)}</>);
|
|
}
|
|
else if(oldValue !== undefined)
|
|
{
|
|
return (<>Removed {fieldLabel} value {wrapValue(oldValue)}</>);
|
|
}
|
|
*/
|
|
|
|
/*
|
|
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}:</span>;
|
|
if(oldValue !== undefined && newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} {wrapValue(newValue)} (was {oldValue})</>);
|
|
}
|
|
else if(newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} {wrapValue(newValue)} (was --)</>);
|
|
}
|
|
else if(oldValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} {wrapValue("--")} (was {oldValue})</>);
|
|
}
|
|
*/
|
|
|
|
/*
|
|
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}:</span>;
|
|
if(oldValue !== undefined && newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} {newValue} {wasValue(`(was ${oldValue})`)}</>);
|
|
}
|
|
else if(newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} {newValue} {wasValue("(was --)")}</>);
|
|
}
|
|
else if(oldValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} -- {wasValue(`(was ${oldValue})`)}</>);
|
|
}
|
|
*/
|
|
|
|
/*
|
|
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}:</span>;
|
|
if(oldValue !== undefined && newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} Changed to {wrapValue(newValue)} (was {oldValue})</>);
|
|
}
|
|
else if(newValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} Set to {wrapValue(newValue)}</>);
|
|
}
|
|
else if(oldValue !== undefined)
|
|
{
|
|
return (<>{fieldLabel} Removed value (was {oldValue})</>);
|
|
}
|
|
*/
|
|
}
|
|
else if (message)
|
|
{
|
|
return (<>{message}</>);
|
|
}
|
|
return (null);
|
|
}
|
|
|
|
useEffect(() =>
|
|
{
|
|
(async () =>
|
|
{
|
|
/////////////////////////////////
|
|
// setup filter to load audits //
|
|
/////////////////////////////////
|
|
const filter = new QQueryFilter([
|
|
new QFilterCriteria("auditTable.name", QCriteriaOperator.EQUALS, [tableMetaData.name]),
|
|
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, [recordId]),
|
|
], [
|
|
new QFilterOrderBy("timestamp", sortDirection),
|
|
new QFilterOrderBy("id", sortDirection),
|
|
new QFilterOrderBy("auditDetail.id", true)
|
|
], null, "AND", 0, limit);
|
|
|
|
///////////////////////////////
|
|
// fetch audits in try-catch //
|
|
///////////////////////////////
|
|
let audits = [] as QRecord[];
|
|
try
|
|
{
|
|
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
|
|
setAudits(audits);
|
|
}
|
|
catch (e)
|
|
{
|
|
if (e instanceof QException)
|
|
{
|
|
if ((e as QException).status === 403)
|
|
{
|
|
setStatusString("You do not have permission to view audits");
|
|
return;
|
|
}
|
|
}
|
|
|
|
setStatusString("Error loading audits");
|
|
}
|
|
|
|
// if we fetched the limit
|
|
if (audits.length == limit)
|
|
{
|
|
const [count, distinctCount] = await qController.count("audit", filter, null, true); // todo validate distinct working here!
|
|
setTotal(distinctCount);
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// group the audits by auditId (e.g., this is a list that joined audit & auditDetail, so un-flatten it) //
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
const unflattenedAudits: QRecord[] = [];
|
|
const detailMap: Map<number, JSX.Element[]> = new Map();
|
|
const fieldChangeRowsMap: Map<number, JSX.Element[]> = new Map();
|
|
for (let i = 0; i < audits.length; i++)
|
|
{
|
|
let id = audits[i].values.get("id");
|
|
if (i == 0 || unflattenedAudits[unflattenedAudits.length - 1].values.get("id") != id)
|
|
{
|
|
unflattenedAudits.push(audits[i]);
|
|
}
|
|
|
|
let auditDetail = getAuditDetailElement(audits[i]);
|
|
if (auditDetail)
|
|
{
|
|
if (!detailMap.has(id))
|
|
{
|
|
detailMap.set(id, []);
|
|
}
|
|
|
|
detailMap.get(id).push(auditDetail);
|
|
}
|
|
|
|
// table version, probably not to commit
|
|
let fieldChangeRow = getAuditDetailFieldChangeRow(audits[i]);
|
|
if (auditDetail)
|
|
{
|
|
if (!fieldChangeRowsMap.has(id))
|
|
{
|
|
fieldChangeRowsMap.set(id, []);
|
|
}
|
|
// fieldChangeRowsMap.get(id).push(fieldChangeRow)
|
|
}
|
|
}
|
|
audits = unflattenedAudits;
|
|
setAuditDetailMap(detailMap);
|
|
|
|
const fieldChangeMap: Map<number, JSX.Element> = new Map();
|
|
for (let i = 0; i < unflattenedAudits.length; i++)
|
|
{
|
|
let id = unflattenedAudits[i].values.get("id");
|
|
if (fieldChangeRowsMap.has(id) && fieldChangeRowsMap.get(id).length > 0)
|
|
{
|
|
const fieldChangeTable = (
|
|
<table style={{fontSize: "0.875rem"}} className="auditDetailTable" cellSpacing="0">
|
|
<thead>
|
|
<tr>
|
|
<td>Field</td>
|
|
<td>Old Value</td>
|
|
<td>New Value</td>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{fieldChangeRowsMap.get(id).map((row, key) => <React.Fragment key={key}>{row}</React.Fragment>)}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
fieldChangeMap.set(id, fieldChangeTable);
|
|
}
|
|
}
|
|
setFieldChangeMap(fieldChangeMap);
|
|
|
|
//////////////////////////////
|
|
// group the audits by date //
|
|
//////////////////////////////
|
|
const auditsByDate = [];
|
|
let thisDatesAudits = null as QRecord[];
|
|
let lastDate = null;
|
|
for (let i = 0; i < audits.length; i++)
|
|
{
|
|
const audit = audits[i];
|
|
const date = ValueUtils.formatDateTime(audit.values.get("timestamp")).split(" ")[0];
|
|
if (date != lastDate)
|
|
{
|
|
thisDatesAudits = [];
|
|
auditsByDate.push(thisDatesAudits);
|
|
lastDate = date;
|
|
}
|
|
thisDatesAudits.push(audit);
|
|
}
|
|
setAuditsByDate(auditsByDate);
|
|
|
|
///////////////////////////
|
|
// set the status string //
|
|
///////////////////////////
|
|
if (audits.length == 0)
|
|
{
|
|
setStatusString("No audits were found for this record.");
|
|
}
|
|
else
|
|
{
|
|
if (total)
|
|
{
|
|
setStatusString(`Showing first ${limit?.toLocaleString()} of ${total?.toLocaleString()} audits for this record`);
|
|
}
|
|
else
|
|
{
|
|
if (audits.length == 1)
|
|
{
|
|
setStatusString("Showing the only audit for this record");
|
|
}
|
|
else if (audits.length == 2)
|
|
{
|
|
setStatusString("Showing the only 2 audits for this record");
|
|
}
|
|
else
|
|
{
|
|
setStatusString(`Showing all ${audits.length?.toLocaleString()} audits for this record`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)();
|
|
}, [sortDirection]);
|
|
|
|
const changeSortDirection = () =>
|
|
{
|
|
setAudits([]);
|
|
const newSortDirection = !sortDirection;
|
|
setSortDirection(newSortDirection);
|
|
localStorage.setItem("audit.sortDirection", String(newSortDirection));
|
|
};
|
|
|
|
const todayFormatted = ValueUtils.formatDateTime(new Date()).split(" ")[0];
|
|
const yesterday = new Date();
|
|
yesterday.setTime(yesterday.getTime() - 24 * 60 * 60 * 1000);
|
|
const yesterdayFormatted = ValueUtils.formatDateTime(yesterday).split(" ")[0];
|
|
|
|
return (
|
|
<Box>
|
|
<Box p={3} display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start">
|
|
<Typography variant="h5" pb={3}>
|
|
Audit for {tableMetaData.label}: {record?.recordLabel ?? recordId}
|
|
<Typography fontSize={14}>
|
|
{statusString}
|
|
</Typography>
|
|
</Typography>
|
|
<Box>
|
|
<Typography variant="button" pr={1}>Sort</Typography>
|
|
<ToggleButtonGroup
|
|
value={sortDirection}
|
|
exclusive
|
|
onChange={changeSortDirection}
|
|
aria-label="text alignment"
|
|
>
|
|
<ToggleButton value={true} aria-label="sort ascending">
|
|
<Tooltip title="Sort by time ascending (oldest to newest)" placement="bottom">
|
|
<Icon>arrow_upward</Icon>
|
|
</Tooltip>
|
|
</ToggleButton>
|
|
<ToggleButton value={false} aria-label="sort descending">
|
|
<Tooltip title="Sort by time descending (newest to oldest)" placement="bottom">
|
|
<Icon>arrow_downward</Icon>
|
|
</Tooltip>
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{overflow: "auto", height: "calc( 100vh - 19rem )", position: "relative"}} px={3}>
|
|
{
|
|
auditsByDate.length ? auditsByDate.map((audits) =>
|
|
{
|
|
if (audits.length)
|
|
{
|
|
const audit0 = audits[0];
|
|
const formattedTimestamp = ValueUtils.formatDateTime(audit0.values.get("timestamp"));
|
|
const timestampParts = formattedTimestamp.split(" ");
|
|
|
|
return (
|
|
<Box key={audit0.values.get("id")} className="auditGroupBlock">
|
|
<Box position="sticky" top="0" zIndex={3}>
|
|
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14} position={"relative"} top={"-1px"} pb={"6px"} sx={{backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 80%, rgba(255,255,255,0))"}}>
|
|
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
|
|
<Box whiteSpace="nowrap">
|
|
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
|
|
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
|
|
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
|
|
</Box>
|
|
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
|
|
</Box>
|
|
</Box>
|
|
|
|
{
|
|
audits.map((audit) =>
|
|
{
|
|
const formattedTimestamp = ValueUtils.formatDateTime(audit.values.get("timestamp"));
|
|
const timestampParts = formattedTimestamp.split(" ");
|
|
|
|
return (
|
|
<Box key={audit.values.get("id")} display="flex" flexDirection="row" mb={1} className="singleAuditBlock">
|
|
<Avatar sx={{bgcolor: accentColor, zIndex: 2}}>
|
|
<Icon>check</Icon>
|
|
</Avatar>
|
|
<Box p={1} width="100%">
|
|
<Box fontSize="0.875rem" color="rgb(123, 128, 154)">
|
|
{timestampParts[1]} {timestampParts[2]} {timestampParts[3]} {audit.displayValues.get("auditUserId")}
|
|
</Box>
|
|
<Box fontSize="0.875rem">
|
|
{audit.values.get("message")}
|
|
</Box>
|
|
<Box fontSize="0.875rem">
|
|
<ul style={{"marginLeft": "1rem"}}>
|
|
{
|
|
auditDetailMap.get(audit.values.get("id"))?.map((detail, key) =>
|
|
{
|
|
return (<li key={key}>{detail}</li>);
|
|
})
|
|
}
|
|
</ul>
|
|
</Box>
|
|
{
|
|
fieldChangeMap.has(audit.values.get("id")) && fieldChangeMap.get(audit.values.get("id"))
|
|
}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})
|
|
}
|
|
</Box>
|
|
);
|
|
}
|
|
else
|
|
{
|
|
return <></>;
|
|
}
|
|
}) : <></>
|
|
}
|
|
</Box>
|
|
</Box>);
|
|
}
|
|
|
|
export default AuditBody;
|