|
| 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