From ae8d8fa900a8bea0a79def5f231d996e26180eda Mon Sep 17 00:00:00 2001 From: jordancj Date: Tue, 13 Jan 2026 15:24:18 +1100 Subject: [PATCH 1/7] Added roles, reporting & remove empty columns Signed-off-by: jordancj --- api/src/index.ts | 61 +++++++++++++-- api/src/middleware/sanitiseInputs.ts | 77 +++++++++++++++++-- frontend/src/Components/checkboxContainer.jsx | 47 +++++++++++ frontend/src/Pages/Admin/reports.jsx | 32 +++++++- frontend/src/Pages/Attendance/Non-op-page.jsx | 21 ++++- frontend/src/Pages/Attendance/Op-page.jsx | 19 ++++- 6 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 frontend/src/Components/checkboxContainer.jsx diff --git a/api/src/index.ts b/api/src/index.ts index 815e536..34a87eb 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -455,10 +455,11 @@ const tokenData = await fetchOrThrow( activity, operational, detailed, - includeZeroAttendance + includeZeroAttendance, + roles } = req.body; try { - const MAX_SPAN = 1095 * 24 * 60 * 60 * 1000; // 1 year ms + const MAX_SPAN = 1095 * 24 * 60 * 60 * 1000; // 3 years ms if (endEpoch - startEpoch > MAX_SPAN) { res.status(400).json({ message: 'Date range too large (max 3 years)' }); return; @@ -470,6 +471,9 @@ const tokenData = await fetchOrThrow( if (name) query.name = name; if (activity) query.activity = activity; if (operational) query.operational = operational; + if(Array.isArray(roles) && roles.length === 1){ + query.roles = roles[0] + } const MAX_ROWS = 50000; const recordsCursor = recordsCollection.find(query).limit(MAX_ROWS + 1); const records = await recordsCursor.toArray(); @@ -557,6 +561,35 @@ const tokenData = await fetchOrThrow( const reportExport: RequestHandler = async (req, res) => { const authedReq = req as AuthedRequest; authedReq.user = authedReq.session.user; + function isEmptyCellValue(v: unknown): boolean{ + if (v == null) return true; + if (typeof v === "string") return v.trim() === "" + if (typeof v === "object"){ + const obj = v as any; + if (obj.rechText) return obj.richText.length === 0; + if (obj.text) return String(obj.text).trim() === ""; + if (obj.formula) return false; + if (obj.result != null) return false; + } + return false +} +function deleteColumnIfEmpty( + worksheet: ExcelJS.Worksheet, + colNumber: number, + headerRowNumber = 1 +) { + let hasData = false; + + worksheet.eachRow({includeEmpty: true}, (row, rowNumber) => { + if (rowNumber <= headerRowNumber) return; + + const cellValue = row.getCell(colNumber).value; + if(!isEmptyCellValue(cellValue)) hasData = true; + }); + if (!hasData){ + worksheet.spliceColumns(colNumber, 1); + } +} const { startEpoch, endEpoch, @@ -564,6 +597,7 @@ const tokenData = await fetchOrThrow( activity, operational, includeZeroAttendance, + roles, detailed, formattedStart, formattedEnd @@ -577,7 +611,9 @@ const tokenData = await fetchOrThrow( if (name) query.name = name; if (activity) query.activity = activity; if (operational) query.operational = operational; - + if(Array.isArray(roles) && roles.length === 1){ + query.roles = roles[0] + }; const MAX_ROWS = 50000; const recordsCursor = recordsCollection.find(query).limit(MAX_ROWS + 1); const records = await recordsCursor.toArray(); @@ -620,6 +656,7 @@ const tokenData = await fetchOrThrow( ...(record.deploymentType && { deploymentType: record.deploymentType }), ...(record.otherType && { otherType: record.otherType }), ...(record.deploymentLocation && { deploymentLocation: record.deploymentLocation }), + ...(record.roles && { roles: record.roles}) }); if (record.operational === "Operational") userStats.operationalActivities++; @@ -652,6 +689,7 @@ const tokenData = await fetchOrThrow( ...(record.deploymentType && { deploymentType: record.deploymentType }), ...(record.otherType && { otherType: record.otherType }), ...(record.deploymentLocation && { deploymentLocation: record.deploymentLocation }), + ...(record.roles && { roles: record.roles}) }); } } @@ -698,6 +736,7 @@ const tokenData = await fetchOrThrow( 'Activity', 'Activity Detail', 'Activity Location', + 'roles' ]; worksheet.addRow(header); } @@ -729,6 +768,10 @@ const tokenData = await fetchOrThrow( activityType = record.deploymentType || ""; activityLocation = record.deploymentLocation || ""; } + const roles = + Array.isArray(record.roles) && record.roles.length > 0 + ? record.roles.join(", ") + : "" const row = [ record.timestampLocal, user.name, @@ -739,7 +782,8 @@ const tokenData = await fetchOrThrow( record.operational, record.activity, activityType, - activityLocation + activityLocation, + roles ]; worksheet.addRow(row); } @@ -757,7 +801,9 @@ const tokenData = await fetchOrThrow( ] worksheet.addRow(row) }) - + for(let i = 11; i > 1; i--){ + deleteColumnIfEmpty(worksheet, i) + } const fallbackFormat = (epoch: number) => new Date(epoch).toISOString().slice(0, 10).replace(/-/g, ''); const fileStart = formattedStart || fallbackFormat(startEpoch); const fileEnd = formattedEnd || fallbackFormat(endEpoch); @@ -787,7 +833,6 @@ const tokenData = await fetchOrThrow( const exists = await usersCollection.findOne({ username }); if (!exists){ - console.log(username) res.status(404).json({ ok: false }); return;} req.session.validUsername = username; // 🔑 remember validation in this session @@ -818,12 +863,14 @@ const tokenData = await fetchOrThrow( chainsawType, deploymentType, deploymentLocation, - otherType + otherType, + roles } = req.body; const record: any = { name, operational, activity, + roles, epochTimestamp }; // Conditional data fields based on activity type diff --git a/api/src/middleware/sanitiseInputs.ts b/api/src/middleware/sanitiseInputs.ts index 0b6a1b8..d6635f9 100644 --- a/api/src/middleware/sanitiseInputs.ts +++ b/api/src/middleware/sanitiseInputs.ts @@ -1,6 +1,39 @@ import { Request, Response, NextFunction } from 'express'; import moment from 'moment'; import validator from 'validator'; + +const allowedRoles = new Set([ + "Crew Leader", + "Driver", + "Pump Operator", + "BA Operator", + "BACO", + "Hose Operator", + "Chainsaw Operator", +]); + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function sanitiseRoles(value: unknown, opts?: { max?: number }): string[] | null { + if (value == null) return []; + if (!isStringArray(value)) return null; + + const max = opts?.max ?? 20; + if (value.length > max) return null; + + const sanitised = value + .map((r) => validator.trim(r).replace(/\0/g, "")) + .filter((r) => r.length > 0); + + for (const r of sanitised) { + if (!allowedRoles.has(r)) return null; + } + + return Array.from(new Set(sanitised)); +} + export function sanitizeAttendanceInput(req: Request, res: Response, next: NextFunction) { const { name, @@ -11,9 +44,18 @@ export function sanitizeAttendanceInput(req: Request, res: Response, next: NextF chainsawType, deploymentType, deploymentLocation, - otherType + otherType, + roles } = req.body; + const sanitisedRoles = sanitiseRoles(roles); + if (sanitisedRoles === null){ + res.status(400).json({ + message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`, + }); + return + } + const sanitized = { name: validator.trim(name || ''), operational: validator.trim(operational || ''), @@ -22,7 +64,8 @@ export function sanitizeAttendanceInput(req: Request, res: Response, next: NextF chainsawType: validator.trim(chainsawType || ''), deploymentType: validator.trim(deploymentType || ''), deploymentLocation: validator.trim(deploymentLocation || ''), - otherType: validator.trim(otherType || '') + otherType: validator.trim(otherType || ''), + roles: sanitisedRoles }; const validators = [ @@ -132,9 +175,18 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex activity, operational, detailed, - includeZeroAttendance + includeZeroAttendance, + roles } = req.body ?? {}; + const sanitisedRoles = sanitiseRoles(roles); + if (sanitisedRoles === null){ + res.status(400).json({ + message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`, + }); + return + } + const asTrimmedString = (v: unknown) => validator.trim(String(v ?? '')); const sanitized = { @@ -142,7 +194,8 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex operational: asTrimmedString(operational), activity: asTrimmedString(activity), detailed: parseBoolean(detailed), - includeZeroAttendance: parseBoolean(includeZeroAttendance) + includeZeroAttendance: parseBoolean(includeZeroAttendance), + roles: sanitisedRoles }; const validators = [ @@ -151,7 +204,7 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex { value: sanitized.activity, pattern: /^[a-zA-Z0-9\s-]+$/, field: 'activity' }, ] as const; - const minMS = moment.tz('2000-01-01 00:00:00', 'Australia/Sydney').valueOf(); + const minMS = moment.tz('2023-01-01 00:00:00', 'Australia/Sydney').valueOf(); const maxMS = moment.tz('2100-12-31 23:59:59.999', 'Australia/Sydney').valueOf(); function isEpochMS(n: unknown): n is number{ return typeof n === 'number' @@ -168,7 +221,7 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex const startEpochMS = Number(startEpoch) const endEpochMS = Number(endEpoch) - if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be be after Jan 1 2000'})} + if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be after Jan 1 2023'})} if (!isEpochMS(endEpochMS)){return res.status(400).json({message: 'End time must be before Dec 31 2100'})} req.body = { ...sanitized, @@ -199,11 +252,20 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next: activity, operational, includeZeroAttendance, + roles, detailed, formattedStart, formattedEnd } = req.body ?? {}; + const sanitisedRoles = sanitiseRoles(roles); + if (sanitisedRoles === null){ + res.status(400).json({ + message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`, + }); + return + } + const sanitized = { name: validator.trim(String(name ?? '')), operational: validator.trim(String(operational ?? '')), @@ -213,6 +275,7 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next: includeZeroAttendance: parseBoolean(includeZeroAttendance), detailed: parseBoolean(detailed), + roles: sanitisedRoles }; function runRule(rule: { value: any; field: string; pattern?: RegExp; validate?: (v: any) => boolean }) { @@ -247,7 +310,7 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next: } const startEpochMS = Number(startEpoch) const endEpochMS = Number(endEpoch) - if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be after Jan 1 2023'})} + if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be bafter Jan 1 2023'})} if (!isEpochMS(endEpochMS)){return res.status(400).json({message: 'End time must be before Dec 31 2100'})} const errors: string[] = []; diff --git a/frontend/src/Components/checkboxContainer.jsx b/frontend/src/Components/checkboxContainer.jsx new file mode 100644 index 0000000..6b90808 --- /dev/null +++ b/frontend/src/Components/checkboxContainer.jsx @@ -0,0 +1,47 @@ +const roles = [ + "Crew Leader", + "Driver", + "Pump Operator", + "BA Operator", + "BACO", + "Hose Operator", + "Chainsaw Operator" +] +export default function CheckboxContainer({selectedRoles = [], setSelectedRoles}){ + console.log(selectedRoles) + function handleChange(e) { + const {value, checked } = e.target + setSelectedRoles((prev) => { + if (checked) return prev.includes(value) ? prev: [...prev, value] + return prev.filter((r) => r !== value); + }) + } + return( +
+
Select the role you actively performed:
+ {roles.map((role) => { + const id = `role-${String(role).toLowerCase().replace(/\s+/g, "-")}`; + return( + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/Pages/Admin/reports.jsx b/frontend/src/Pages/Admin/reports.jsx index 1c7322b..c8c7f50 100644 --- a/frontend/src/Pages/Admin/reports.jsx +++ b/frontend/src/Pages/Admin/reports.jsx @@ -39,6 +39,17 @@ const activityOptions = { ], }; +const roleOptions = + [ + "Crew Leader", + "Driver", + "Pump Operator", + "BA Operator", + "BACO", + "Hose Operator", + "Chainsaw Operator" +]; + export default function Reports({ users = [] }) { const csrfToken = useCsrfToken(apiUrl); useEffect(() => { @@ -53,11 +64,13 @@ export default function Reports({ users = [] }) { operational: '', includeZeroAttendance: false, detailed: false, - incidentType: '' + incidentType: '', + roles: '' }); const [activities, setActivities] = useState(activityOptions.Any); const [reportHTML, setReportHTML] = useState(''); const [userOptions, setUserOptions] = useState([]); + const [roles] = useState(roleOptions); useEffect(() => { fetch(`${apiUrl}/api/users/names`, { @@ -100,7 +113,8 @@ export default function Reports({ users = [] }) { activity: form.activity, operational: form.operational, detailed: form.detailed, - includeZeroAttendance: form.includeZeroAttendance + includeZeroAttendance: form.includeZeroAttendance, + roles: form.roles ? [form.roles] : [] }), }); const result = await res.json(); @@ -124,7 +138,9 @@ export default function Reports({ users = [] }) { const hasActivityType = !!(isDetailed && rows.some(r => r?.deploymentType || r?.baType || r?.chainsawType || r?.otherType )); - + const hasRoles = !!(isDetailed && rows.some (r => + r?.roles + )); const hasActivityLocation = !!(isDetailed && rows.some(r => r?.deploymentLocation)); const headerHtml = isDetailed @@ -135,6 +151,7 @@ export default function Reports({ users = [] }) { Activity ${hasActivityType ? 'Activity Detail' : ''} ${hasActivityLocation ? 'Activity Location' : ''} + ${hasRoles ? 'Roles' : ''} ` : ` Name @@ -155,6 +172,7 @@ export default function Reports({ users = [] }) { ${r.activity ?? ''} ${hasActivityType ? `${r.deploymentType || r.baType || r.chainsawType || r.otherType || ''}` : ''} ${hasActivityLocation ? `${r.deploymentLocation || ''}` : ''} + ${hasRoles ? `${r.roles || ''} `).join('') : rows.map(r => ` @@ -207,6 +225,7 @@ export default function Reports({ users = [] }) { activity: form.activity, operational: form.operational, includeZeroAttendance: form.includeZeroAttendance, + roles: form.roles ? [form.roles] : [], detailed: form.detailed, formattedStart, formattedEnd @@ -266,6 +285,13 @@ export default function Reports({ users = [] }) { {activities.map((a) => )} +
+ + +
diff --git a/frontend/src/Pages/Attendance/Non-op-page.jsx b/frontend/src/Pages/Attendance/Non-op-page.jsx index add69ac..5fd1b18 100644 --- a/frontend/src/Pages/Attendance/Non-op-page.jsx +++ b/frontend/src/Pages/Attendance/Non-op-page.jsx @@ -3,6 +3,7 @@ import styles from "../../styles/Attendance.module.css"; import { useTitle } from '../../hooks/useTitle.jsx'; import {useState, useEffect } from "react"; import {useCsrfToken} from "../../Components/csrfHelper.jsx" +import CheckboxContainer from '../../Components/checkboxContainer.jsx' const activities = [ "Training", @@ -13,6 +14,14 @@ const activities = [ "Chainsaw-Checks", "Other-Non-operational" ]; + +const roleActivities = [ + "Training", + "Community-Engagement", + "Other-Non-operational" + +] + const apiurl = import.meta.env.VITE_API_BASE_URL; export default function OperationalPage() { @@ -25,6 +34,7 @@ export default function OperationalPage() { const [otherType, setOtherType] = useState("") const [selectedActivity, setSelectedActivity] = useState(sessionStorage.getItem("activity") || ""); const [date, setDate] = useState(""); + const [selectedRoles, setSelectedRoles] = useState([]) const navigate = useNavigate(); const handleSelect = (activity) => { @@ -76,7 +86,8 @@ export default function OperationalPage() { epochTimestamp: dateObj.getTime(), ...(activity === "BA-Checks" && { baType: type }), ...(activity === "Chainsaw-Checks" && { chainsawType: type }), - ...(activity === "Other-Non-operational" && { otherType }) + ...(activity === "Other-Non-operational" && { otherType }), + roles:[selectedRoles] } const response = await fetch(`${apiurl}/api/attendance/submit`, { method: "POST", @@ -183,6 +194,14 @@ export default function OperationalPage() {
)} + {roleActivities.includes(selectedActivity) && ( + + )} + +
{ @@ -31,6 +33,7 @@ export default function OperationalPage() { setSelectedActivity(newValue); if (newValue) { sessionStorage.setItem("activity", newValue); + setSelectedRoles([]); } else { sessionStorage.removeItem("activity"); } @@ -55,14 +58,14 @@ export default function OperationalPage() { name: username, operational: activitySelection, activity, + roles: selectedRoles, epochTimestamp: dateObj.getTime(), ...(activity === "Deployment" && { deploymentType, - deploymentLocation + deploymentLocation, }), ...(activity === "Other-operational" && { otherType }) }; - console.log(data) try { const response = await fetch(`${apiurl}/api/attendance/submit`, { @@ -94,6 +97,7 @@ export default function OperationalPage() { navigate("/attendance"); } }; + useTitle('Operational Attendance'); return (
@@ -159,6 +163,14 @@ export default function OperationalPage() {
)} + + {selectedActivity && ( + + )} +
setDate(e.target.value)} />
-