Skip to content

Commit d016ee5

Browse files
committed
376 implement organization audit log ui
1 parent 04c1164 commit d016ee5

7 files changed

Lines changed: 652 additions & 1 deletion

File tree

client/src/components/Admin/OrganizationEdit.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ import {
4444
TENANT_ID,
4545
} from "../../helpers/Constants";
4646
import { useSuggestionByStakeholderId } from "hooks/useSuggestionByStakeholderId";
47+
import { useStakeholderLog } from "hooks/useStakeholderLog";
4748
import SuggestionHistory from "./OrganizationEdit/SuggestionHistory";
49+
import ChangeHistory from "./OrganizationEdit/ChangeHistory";
4850

4951
const phoneRegExp = /^\(\d{3}\) \d{3}-\d{4}$/;
5052

@@ -226,6 +228,12 @@ const OrganizationEdit = (props) => {
226228
const { data: stakeholderSuggestions, refetch: refetchSuggestions } =
227229
useSuggestionByStakeholderId(editId);
228230

231+
const {
232+
data: versionHistory,
233+
loading: historyLoading,
234+
error: historyError,
235+
} = useStakeholderLog(editId);
236+
229237
useEffect(() => {
230238
const fetchData = async () => {
231239
try {
@@ -561,6 +569,9 @@ const OrganizationEdit = (props) => {
561569
<Tab label="Donations" {...a11yProps(4)} />
562570
<Tab label="Verification" {...a11yProps(5)} />
563571
<Tab label="Suggestion History" {...a11yProps(6)} />
572+
{(user?.isAdmin || user?.isCoordinator) && (
573+
<Tab label="Change History" {...a11yProps(7)} />
574+
)}
564575
</Tabs>
565576
</AppBar>
566577
<Identification
@@ -623,6 +634,14 @@ const OrganizationEdit = (props) => {
623634
editedSuggestions={editedSuggestions}
624635
onEdit={handleSuggestionEdit}
625636
/>
637+
{(user?.isAdmin || user?.isCoordinator) && (
638+
<ChangeHistory
639+
tabPage={tabPage}
640+
versions={versionHistory}
641+
loading={historyLoading}
642+
error={historyError}
643+
/>
644+
)}
626645
</Box>
627646
<Stack direction="row">
628647
<div style={{ flexBasis: "20%", flexGrow: 1 }}>
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import {
2+
Box,
3+
FormControl,
4+
FormControlLabel,
5+
InputLabel,
6+
MenuItem,
7+
Paper,
8+
Select,
9+
Stack,
10+
Switch,
11+
Table,
12+
TableBody,
13+
TableCell,
14+
TableContainer,
15+
TableHead,
16+
TableRow,
17+
Typography,
18+
CircularProgress,
19+
} from "@mui/material";
20+
import { useState } from "react";
21+
import PropTypes from "prop-types";
22+
import { TabPanel } from "../ui/TabPanel";
23+
import { VERIFICATION_STATUS_NAMES } from "constants/stakeholder";
24+
import VersionComparison from "./VersionComparison";
25+
26+
function formatDate(dateStr) {
27+
if (!dateStr) return "N/A";
28+
return new Date(dateStr).toLocaleDateString("en-US", {
29+
year: "numeric",
30+
month: "short",
31+
day: "numeric",
32+
hour: "2-digit",
33+
minute: "2-digit",
34+
});
35+
}
36+
37+
// Fields to exclude from comparison (metadata, computed fields)
38+
const EXCLUDED_FIELDS = [
39+
"id",
40+
"version",
41+
"createdDate",
42+
"createdUser",
43+
"createdLoginId",
44+
"modifiedDate",
45+
"modifiedUser",
46+
"modifiedLoginId",
47+
"modifiedUserRole",
48+
"categories", // compare selectedCategoryIds instead
49+
];
50+
51+
export function getComparableFields() {
52+
// This will be populated from the stakeholder object keys
53+
// minus the excluded fields
54+
return [
55+
"name",
56+
"address1",
57+
"address2",
58+
"city",
59+
"state",
60+
"zip",
61+
"phone",
62+
"phoneExt",
63+
"email",
64+
"website",
65+
"latitude",
66+
"longitude",
67+
"description",
68+
"notes",
69+
"requirements",
70+
"adminNotes",
71+
"inactive",
72+
"inactiveTemporary",
73+
"parentOrganization",
74+
"physicalAccess",
75+
"items",
76+
"services",
77+
"facebook",
78+
"twitter",
79+
"pinterest",
80+
"linkedin",
81+
"instagram",
82+
"adminContactName",
83+
"adminContactPhone",
84+
"adminContactEmail",
85+
"donationContactName",
86+
"donationContactPhone",
87+
"donationContactEmail",
88+
"donationPickup",
89+
"donationAcceptFrozen",
90+
"donationAcceptRefrigerated",
91+
"donationAcceptPerishable",
92+
"donationSchedule",
93+
"donationDeliveryInstructions",
94+
"donationNotes",
95+
"covidNotes",
96+
"categoryNotes",
97+
"eligibilityNotes",
98+
"foodTypes",
99+
"languages",
100+
"hours",
101+
"hoursNotes",
102+
"verificationStatusId",
103+
"confirmedName",
104+
"confirmedCategories",
105+
"confirmedAddress",
106+
"confirmedEmail",
107+
"confirmedPhone",
108+
"confirmedHours",
109+
"confirmedFoodTypes",
110+
"foodBakery",
111+
"foodDryGoods",
112+
"foodProduce",
113+
"foodDairy",
114+
"foodPrepared",
115+
"foodMeat",
116+
"allowWalkins",
117+
"selectedCategoryIds",
118+
"tags",
119+
];
120+
}
121+
122+
export default function ChangeHistory({
123+
tabPage,
124+
versions = [],
125+
loading = false,
126+
error = null,
127+
}) {
128+
const [selectedVersionA, setSelectedVersionA] = useState(null); // newer/current
129+
const [selectedVersionB, setSelectedVersionB] = useState(null); // older/previous
130+
const [showOnlyChanges, setShowOnlyChanges] = useState(true);
131+
132+
// Count changes between two versions
133+
const countChanges = (versionA, versionB) => {
134+
if (!versionA || !versionB) return 0;
135+
const fieldsToCompare = getComparableFields();
136+
return fieldsToCompare.filter(
137+
(field) => JSON.stringify(versionA[field]) !== JSON.stringify(versionB[field])
138+
).length;
139+
};
140+
141+
// Auto-select first two versions when data loads
142+
if (versions.length >= 2 && !selectedVersionA && !selectedVersionB) {
143+
setSelectedVersionA(versions[0]); // most recent
144+
setSelectedVersionB(versions[1]); // previous
145+
} else if (versions.length === 1 && !selectedVersionA) {
146+
setSelectedVersionA(versions[0]);
147+
}
148+
149+
const changeCount = countChanges(selectedVersionA, selectedVersionB);
150+
151+
if (loading) {
152+
return (
153+
<TabPanel value={tabPage} index={7}>
154+
<Box display="flex" justifyContent="center" p={4}>
155+
<CircularProgress />
156+
</Box>
157+
</TabPanel>
158+
);
159+
}
160+
161+
if (error) {
162+
return (
163+
<TabPanel value={tabPage} index={7}>
164+
<Typography color="error">Error loading history: {error.message}</Typography>
165+
</TabPanel>
166+
);
167+
}
168+
169+
return (
170+
<TabPanel value={tabPage} index={7}>
171+
<Stack spacing={3}>
172+
{/* Header with change count */}
173+
<Typography variant="h6">
174+
Change History {changeCount > 0 && `(${changeCount} changes)`}
175+
</Typography>
176+
177+
{/* Version selector dropdowns */}
178+
<Stack direction="row" spacing={2} alignItems="center">
179+
<FormControl sx={{ minWidth: 250 }}>
180+
<InputLabel>Compare Version (Current)</InputLabel>
181+
<Select
182+
value={selectedVersionA?.version || ""}
183+
label="Compare Version (Current)"
184+
onChange={(e) => {
185+
const v = versions.find((v) => v.version === e.target.value);
186+
setSelectedVersionA(v);
187+
}}
188+
>
189+
{versions.map((v) => (
190+
<MenuItem key={v.version} value={v.version}>
191+
v{v.version} - {formatDate(v.modifiedDate)} ({v.modifiedUser})
192+
</MenuItem>
193+
))}
194+
</Select>
195+
</FormControl>
196+
197+
<Typography>vs</Typography>
198+
199+
<FormControl sx={{ minWidth: 250 }}>
200+
<InputLabel>Compare Version (Previous)</InputLabel>
201+
<Select
202+
value={selectedVersionB?.version || ""}
203+
label="Compare Version (Previous)"
204+
onChange={(e) => {
205+
const v = versions.find((v) => v.version === e.target.value);
206+
setSelectedVersionB(v);
207+
}}
208+
>
209+
{versions.map((v) => (
210+
<MenuItem key={v.version} value={v.version}>
211+
v{v.version} - {formatDate(v.modifiedDate)} ({v.modifiedUser})
212+
</MenuItem>
213+
))}
214+
</Select>
215+
</FormControl>
216+
217+
<FormControlLabel
218+
control={
219+
<Switch
220+
checked={showOnlyChanges}
221+
onChange={(e) => setShowOnlyChanges(e.target.checked)}
222+
/>
223+
}
224+
label="Show only changes"
225+
/>
226+
</Stack>
227+
228+
{/* Version list table */}
229+
<TableContainer component={Paper} sx={{ maxHeight: 300 }}>
230+
<Table stickyHeader size="small">
231+
<TableHead>
232+
<TableRow>
233+
<TableCell>Version</TableCell>
234+
<TableCell>Editor</TableCell>
235+
<TableCell>Role</TableCell>
236+
<TableCell>Date</TableCell>
237+
<TableCell>Status</TableCell>
238+
</TableRow>
239+
</TableHead>
240+
<TableBody>
241+
{versions.map((v) => (
242+
<TableRow
243+
key={v.version}
244+
hover
245+
selected={
246+
v.version === selectedVersionA?.version ||
247+
v.version === selectedVersionB?.version
248+
}
249+
sx={{ cursor: "pointer" }}
250+
>
251+
<TableCell>v{v.version}</TableCell>
252+
<TableCell>{v.modifiedUser || "Unknown"}</TableCell>
253+
<TableCell>{v.modifiedUserRole || "Unknown"}</TableCell>
254+
<TableCell>{formatDate(v.modifiedDate)}</TableCell>
255+
<TableCell>
256+
{VERIFICATION_STATUS_NAMES[v.verificationStatusId] || "Unknown"}
257+
</TableCell>
258+
</TableRow>
259+
))}
260+
</TableBody>
261+
</Table>
262+
</TableContainer>
263+
264+
{/* Comparison view */}
265+
{selectedVersionA && selectedVersionB && (
266+
<VersionComparison
267+
versionA={selectedVersionA}
268+
versionB={selectedVersionB}
269+
showOnlyChanges={showOnlyChanges}
270+
/>
271+
)}
272+
273+
{versions.length === 0 && (
274+
<Typography>No change history available.</Typography>
275+
)}
276+
277+
{versions.length === 1 && (
278+
<Typography>Only one version exists. No comparison available.</Typography>
279+
)}
280+
</Stack>
281+
</TabPanel>
282+
);
283+
}
284+
285+
ChangeHistory.propTypes = {
286+
tabPage: PropTypes.number.isRequired,
287+
versions: PropTypes.array,
288+
loading: PropTypes.bool,
289+
error: PropTypes.object,
290+
};

0 commit comments

Comments
 (0)