From f0507beb88f4122f66b265475495e71500857457 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Sun, 5 Apr 2026 01:37:31 -0400 Subject: [PATCH] Implemented systems for editing training reports. --- api/src/routes/course.ts | 113 +++++- api/src/services/db/CourseSerivce.ts | 350 +++++++++++++++++- api/src/services/logging/auditLog.ts | 41 +- .../trainingReport/trainingReportForm.vue | 120 +++++- ui/src/pages/TrainingReport.vue | 58 ++- ui/src/router/index.ts | 1 + 6 files changed, 634 insertions(+), 49 deletions(-) diff --git a/api/src/routes/course.ts b/api/src/routes/course.ts index adf29f9..ae15ca3 100644 --- a/api/src/routes/course.ts +++ b/api/src/routes/course.ts @@ -1,12 +1,84 @@ import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course"; -import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent, updateCourseEvent } from "../services/db/CourseSerivce"; +import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent, editCourseEventReport } from "../services/db/CourseSerivce"; import { Request, Response, Router } from "express"; -import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; +import { requireLogin, requireMemberState } from "../middleware/auth"; import { MemberState } from "@app/shared/types/member"; import { logger } from "../services/logging/logger"; import { audit } from "../services/logging/auditLog"; import { syncQualificationsForCourseEvent } from "../services/db/qualificationService"; +function validateTrainingReportPayload(payload: any): { valid: true; value: any } | { valid: false; message: string } { + if (!payload || typeof payload !== "object") { + return { valid: false, message: "Payload must be an object" }; + } + + if (!Number.isInteger(payload.course_id)) { + return { valid: false, message: "Must select a training" }; + } + + const eventDate = new Date(payload.event_date); + if (isNaN(eventDate.getTime())) { + return { valid: false, message: "Must be a valid date" }; + } + + const attendees = Array.isArray(payload.attendees) ? payload.attendees : []; + const trainerRole = 1; + const traineeRole = 2; + const seenAttendees = new Set(); + let hasTrainer = false; + let hasTrainee = false; + + for (let i = 0; i < attendees.length; i++) { + const attendee = attendees[i]; + + if (!attendee || typeof attendee !== "object") { + return { valid: false, message: `Attendee at index ${i} is invalid` }; + } + + if (!Number.isInteger(attendee.attendee_id) || attendee.attendee_id <= 0) { + return { valid: false, message: "Must select a member" }; + } + + if (!Number.isInteger(attendee.attendee_role_id) || attendee.attendee_role_id <= 0) { + return { valid: false, message: "Must select a role" }; + } + + if (typeof attendee.passed_bookwork !== "boolean" || typeof attendee.passed_qual !== "boolean") { + return { valid: false, message: "Attendee pass fields must be boolean" }; + } + + if (typeof attendee.remarks !== "string") { + return { valid: false, message: "Attendee remarks must be a string" }; + } + + if (seenAttendees.has(attendee.attendee_id)) { + return { valid: false, message: "Cannot have duplicate attendee." }; + } + + seenAttendees.add(attendee.attendee_id); + hasTrainer = hasTrainer || attendee.attendee_role_id === trainerRole; + hasTrainee = hasTrainee || attendee.attendee_role_id === traineeRole; + } + + if (!hasTrainer) { + return { valid: false, message: "At least one Primary Trainer is required." }; + } + + if (!hasTrainee) { + return { valid: false, message: "At least one Trainee is required." }; + } + + return { + valid: true, + value: { + ...payload, + attendees, + event_date: eventDate, + remarks: payload.remarks ?? null, + }, + }; +} + const cr = Router(); const er = Router(); @@ -69,6 +141,11 @@ er.get('/', async (req: Request, res: Response) => { } const sortDir = allowedSorts.get(sort); + if (!sortDir) { + return res.status(400).json({ + message: `Invalid sort direction '${req.query.sort}'. Allowed values are 'ascending' or 'descending'.` + }); + } let events = await getCourseEvents(sortDir, search, page, pageSize); res.status(200).json(events); @@ -145,21 +222,34 @@ er.post('/', async (req: Request, res: Response) => { } }) -er.put('/:id', [requireLogin, requireMemberState(MemberState.Member), requireRole("dev")], async (req: Request, res: Response) => { +er.put('/:id', async (req: Request, res: Response) => { const editorID: number = req.user.id; const reportId = Number(req.params.id); try { - let data: CourseEventDetails = req.body; - data.event_date = new Date(data.event_date); + const validation = validateTrainingReportPayload(req.body); + if (validation.valid === false) { + return res.status(400).json({ + message: 'Invalid training report payload', + errors: validation.message, + }); + } - await updateCourseEvent(reportId, data); - const syncOutcome = await syncQualificationsForCourseEvent(reportId, editorID); + const parsed = validation.value; + let data: CourseEventDetails = { + ...req.body, + ...parsed, + id: reportId, + event_date: parsed.event_date, + }; - audit.course('report_edited', { actorId: editorID, targetId: reportId }, { qualificationSync: syncOutcome }); - logger.info('app', 'Training report edited', { user: editorID, report: reportId, qualificationSync: syncOutcome }) + const actorRoles = (req.user.roles || []).map((role) => role.name); + const result = await editCourseEventReport(reportId, data, editorID, actorRoles); + + logger.info('app', 'Training report edited', { user: editorID, report: reportId, qualificationSync: result.syncOutcome }) res.sendStatus(200); } catch (error) { + const status = typeof (error as any)?.status === 'number' ? (error as any).status : 500; logger.error( 'app', 'Failed to edit training report', @@ -170,6 +260,11 @@ er.put('/:id', [requireLogin, requireMemberState(MemberState.Member), requireRol stack: error instanceof Error ? error.stack : undefined, } ); + + if (status !== 500) { + return res.status(status).json(error instanceof Error ? error.message : String(error)); + } + res.status(500).json("failed to edit training\n" + error) } }) diff --git a/api/src/services/db/CourseSerivce.ts b/api/src/services/db/CourseSerivce.ts index 3c6de8f..8c742cb 100644 --- a/api/src/services/db/CourseSerivce.ts +++ b/api/src/services/db/CourseSerivce.ts @@ -2,6 +2,99 @@ import pool from "../../db" import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course" import { PagedData } from "@app/shared/types/pagination"; import { toDateTime } from "@app/shared/utils/time"; +import { syncQualificationsForCourseEvent } from "./qualificationService"; +import { audit } from "../logging/auditLog"; + +type QualificationSyncOutcome = { + courseId: number | null; + awarded: number; + deactivated: number; + impactedMembers: number; +}; + +type EventEditResult = { + syncOutcome: QualificationSyncOutcome; +}; + +type EditableAttendee = { + attendee_id: number; + attendee_role_id: number | null; + passed_bookwork: boolean; + passed_qual: boolean; + remarks: string | null; + attendee_name: string | null; +}; + +type ModifiedAttendeeDiff = { + attendee_id: number; + attendee_name: string | null; + fields: Record; +}; + +class CourseEditError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +function normalizeRemarks(value: any): string | null { + if (value === null || value === undefined) { + return null; + } + + return String(value); +} + +function toEditableAttendee(attendee: any): EditableAttendee { + return { + attendee_id: Number(attendee.attendee_id), + attendee_role_id: attendee.attendee_role_id === null || attendee.attendee_role_id === undefined ? null : Number(attendee.attendee_role_id), + passed_bookwork: !!attendee.passed_bookwork, + passed_qual: !!attendee.passed_qual, + remarks: normalizeRemarks(attendee.remarks), + attendee_name: attendee.attendee_name ?? null, + }; +} + +function attendeeFieldDiff(before: EditableAttendee, after: EditableAttendee): Record { + const fields: Record = {}; + + if (before.attendee_role_id !== after.attendee_role_id) { + fields.attendee_role_id = { before: before.attendee_role_id, after: after.attendee_role_id }; + } + + if (before.passed_bookwork !== after.passed_bookwork) { + fields.passed_bookwork = { before: before.passed_bookwork, after: after.passed_bookwork }; + } + + if (before.passed_qual !== after.passed_qual) { + fields.passed_qual = { before: before.passed_qual, after: after.passed_qual }; + } + + if (normalizeRemarks(before.remarks) !== normalizeRemarks(after.remarks)) { + fields.remarks = { before: normalizeRemarks(before.remarks), after: normalizeRemarks(after.remarks) }; + } + + return fields; +} + +function deriveTrainerIds(attendees: EditableAttendee[]): number[] { + return attendees + .filter((attendee) => attendee.attendee_role_id === 1) + .map((attendee) => attendee.attendee_id) + .sort((a, b) => a - b); +} + +function hasSeventeenthAdminRole(roles: string[]): boolean { + return roles.some((role) => role.toLowerCase() === "17th administrator"); +} + +function sameDateInstant(left: Date, right: Date): boolean { + return left.getTime() === right.getTime(); +} export async function getAllCourses(): Promise { const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;" @@ -33,8 +126,8 @@ function buildAttendee(row: RawAttendeeRow): CourseAttendee { name: row.role_name, description: row.role_description, deleted: !!row.role_deleted, - created_at: new Date(row.role_created_at), - updated_at: new Date(row.role_updated_at), + created_at: row.role_created_at ? new Date(row.role_created_at) : null, + updated_at: row.role_updated_at ? new Date(row.role_updated_at) : null, } : null }; @@ -74,14 +167,29 @@ export async function getCourseEventDetails(id: number): Promise { + let con: any = null; + try { - var con = await pool.getConnection(); + if (event.course_id === null) { + throw new CourseEditError(400, "Invalid course selection"); + } + + con = await pool.getConnection(); let course: Course = await getCourseByID(event.course_id); @@ -89,7 +197,7 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise { return roles; } -export async function updateCourseEvent(eventId: number, event: CourseEventDetails): Promise { +export async function editCourseEventReport(eventId: number, event: CourseEventDetails, actorId: number, actorRoleNames: string[]): Promise { + let con: any = null; + try { - var con = await pool.getConnection(); - - let course: Course = await getCourseByID(event.course_id); + if (event.course_id === null) { + throw new CourseEditError(400, "Invalid course selection"); + } + con = await pool.getConnection(); await con.beginTransaction(); + const eventRows = await con.query( + `SELECT id, course_id, event_date, remarks, created_by, hasBookwork, hasQual + FROM course_events + WHERE id = ? + LIMIT 1;`, + [eventId] + ); + + if (!eventRows.length) { + throw new CourseEditError(404, "Training report not found"); + } + + const existingEvent = eventRows[0]; + const editorIsAuthor = Number(existingEvent.created_by) === actorId; + const editorIsAdmin = hasSeventeenthAdminRole(actorRoleNames); + + if (!editorIsAuthor && !editorIsAdmin) { + throw new CourseEditError(403, "Only the report author or a 17th administrator can edit this report"); + } + + const courseRows = await con.query( + `SELECT id, hasBookwork, hasQual + FROM courses + WHERE id = ? + LIMIT 1;`, + [event.course_id] + ); + + if (!courseRows.length) { + throw new CourseEditError(400, "Invalid course selection"); + } + + const selectedCourse = courseRows[0]; + + const attendeeRows = await con.query( + `SELECT + ca.attendee_id, + ca.attendee_role_id, + ca.passed_bookwork, + ca.passed_qual, + ca.remarks, + mem.name AS attendee_name + FROM course_attendees ca + LEFT JOIN members mem ON mem.id = ca.attendee_id + WHERE ca.course_event_id = ?;`, + [eventId] + ); + + const existingAttendees: EditableAttendee[] = attendeeRows.map((row: any) => toEditableAttendee(row)); + const incomingAttendees: EditableAttendee[] = (event.attendees ?? []).map((attendee) => toEditableAttendee(attendee)); + + const existingById = new Map(existingAttendees.map((attendee) => [attendee.attendee_id, attendee])); + const incomingById = new Map(incomingAttendees.map((attendee) => [attendee.attendee_id, attendee])); + + const added: EditableAttendee[] = []; + const removed: EditableAttendee[] = []; + const modified: ModifiedAttendeeDiff[] = []; + + for (const attendee of incomingAttendees) { + const existingAttendee = existingById.get(attendee.attendee_id); + if (!existingAttendee) { + added.push(attendee); + continue; + } + + const fields = attendeeFieldDiff(existingAttendee, attendee); + if (Object.keys(fields).length > 0) { + modified.push({ + attendee_id: attendee.attendee_id, + attendee_name: attendee.attendee_name || existingAttendee.attendee_name, + fields, + }); + } + } + + for (const attendee of existingAttendees) { + if (!incomingById.has(attendee.attendee_id)) { + removed.push(attendee); + } + } + + const existingEventDate = new Date(existingEvent.event_date); + const incomingEventDate = new Date(event.event_date); + if (isNaN(incomingEventDate.getTime())) { + throw new CourseEditError(400, "Invalid event date"); + } + + const existingRemarks = normalizeRemarks(existingEvent.remarks); + const incomingRemarks = normalizeRemarks(event.remarks); + + const eventChanges: Record = {}; + + if (Number(existingEvent.course_id) !== Number(event.course_id)) { + eventChanges.course_id = { before: Number(existingEvent.course_id), after: Number(event.course_id) }; + } + + if (!sameDateInstant(existingEventDate, incomingEventDate)) { + eventChanges.event_date = { before: existingEventDate.toISOString(), after: incomingEventDate.toISOString() }; + } + + if (existingRemarks !== incomingRemarks) { + eventChanges.remarks = { before: existingRemarks, after: incomingRemarks }; + } + + const previousTrainerIds = deriveTrainerIds(existingAttendees); + const nextTrainerIds = deriveTrainerIds(incomingAttendees); + if (JSON.stringify(previousTrainerIds) !== JSON.stringify(nextTrainerIds)) { + eventChanges.trainer_attendee_ids = { before: previousTrainerIds, after: nextTrainerIds }; + } + + if (!!existingEvent.hasBookwork !== !!selectedCourse.hasBookwork) { + eventChanges.hasBookwork = { before: !!existingEvent.hasBookwork, after: !!selectedCourse.hasBookwork }; + } + + if (!!existingEvent.hasQual !== !!selectedCourse.hasQual) { + eventChanges.hasQual = { before: !!existingEvent.hasQual, after: !!selectedCourse.hasQual }; + } + + const hasEventChanges = Object.keys(eventChanges).length > 0; + const hasAttendeeChanges = added.length > 0 || removed.length > 0 || modified.length > 0; + + if (!hasEventChanges && !hasAttendeeChanges) { + throw new CourseEditError(409, "No report changes detected"); + } + await con.query( `UPDATE course_events SET course_id = ?, @@ -178,12 +414,10 @@ export async function updateCourseEvent(eventId: number, event: CourseEventDetai hasBookwork = ?, hasQual = ? WHERE id = ?;`, - [event.course_id, toDateTime(event.event_date), event.remarks, course.hasBookwork, course.hasQual, eventId] + [event.course_id, toDateTime(incomingEventDate), incomingRemarks, !!selectedCourse.hasBookwork, !!selectedCourse.hasQual, eventId] ); - await con.query(`DELETE FROM course_attendees WHERE course_event_id = ?;`, [eventId]); - - for (const attendee of event.attendees) { + for (const attendee of added) { await con.query( `INSERT INTO course_attendees ( attendee_id, @@ -194,11 +428,101 @@ export async function updateCourseEvent(eventId: number, event: CourseEventDetai remarks ) VALUES (?, ?, ?, ?, ?, ?);`, - [attendee.attendee_id, eventId, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks] + [ + attendee.attendee_id, + eventId, + attendee.attendee_role_id, + attendee.passed_bookwork, + attendee.passed_qual, + attendee.remarks, + ] ); } + for (const attendee of modified) { + const updates: string[] = []; + const params: any[] = []; + + if (attendee.fields.attendee_role_id) { + updates.push("attendee_role_id = ?"); + params.push(attendee.fields.attendee_role_id.after); + } + + if (attendee.fields.passed_bookwork) { + updates.push("passed_bookwork = ?"); + params.push(attendee.fields.passed_bookwork.after); + } + + if (attendee.fields.passed_qual) { + updates.push("passed_qual = ?"); + params.push(attendee.fields.passed_qual.after); + } + + if (attendee.fields.remarks) { + updates.push("remarks = ?"); + params.push(attendee.fields.remarks.after); + } + + if (updates.length > 0) { + params.push(eventId, attendee.attendee_id); + await con.query( + `UPDATE course_attendees + SET ${updates.join(", ")} + WHERE course_event_id = ? + AND attendee_id = ?;`, + params + ); + } + } + + if (removed.length > 0) { + const removeIds = removed.map((attendee) => attendee.attendee_id); + const placeholders = removeIds.map(() => "?").join(", "); + await con.query( + `DELETE FROM course_attendees + WHERE course_event_id = ? + AND attendee_id IN (${placeholders});`, + [eventId, ...removeIds] + ); + } + + const syncOutcome = await syncQualificationsForCourseEvent(eventId, actorId, con); + + const auditPayload = { + meta: { + reportId: eventId, + actorId, + editedAt: new Date().toISOString(), + authorizationPathUsed: editorIsAuthor ? "author" : "17th_admin", + }, + eventChanges, + attendeeChanges: { + added, + removed, + modified, + }, + qualificationSync: { + ...syncOutcome, + status: "success", + }, + validationContext: { + hasBookwork: !!selectedCourse.hasBookwork, + hasQual: !!selectedCourse.hasQual, + }, + }; + + await audit.course( + "report_edited", + { actorId, targetId: eventId }, + auditPayload, + { connection: con, throwOnError: true } + ); + await con.commit(); + + return { + syncOutcome, + }; } catch (error) { if (con) await con.rollback(); throw error; diff --git a/api/src/services/logging/auditLog.ts b/api/src/services/logging/auditLog.ts index 5474b7e..ce3f178 100644 --- a/api/src/services/logging/auditLog.ts +++ b/api/src/services/logging/auditLog.ts @@ -8,53 +8,64 @@ export interface AuditContext { targetId?: number; // The ID of the thing being changed (target_id) } +interface AuditRecordOptions { + connection?: any; + throwOnError?: boolean; +} + class AuditLogger { async record( area: AuditArea, action: string, context: AuditContext, - data: Record = {} // Already optional with default {} + data: Record = {}, // Already optional with default {} + options: AuditRecordOptions = {} ) { const actionType = `${area}.${action}`; + const queryTarget = options.connection ?? pool; try { - await pool.query( + await queryTarget.query( `INSERT INTO audit_log (action_type, payload, target_id, created_by) VALUES (?, ?, ?, ?)`, // Fixed: removed extra comma/placeholder [ actionType, JSON.stringify(data), - context.targetId || null, + context.targetId ?? null, context.actorId, ] ); } catch (err) { logger.error('audit', `AUDIT_FAILURE: Failed to log ${actionType}`, { error: err }); + + if (options.throwOnError) { + throw err; + } } } - member(action: 'update_rank'| 'update_unit' | 'suspension_added' | 'suspension_removed' | 'discharged', context: AuditContext, data: any = {}) { - return this.record('member', action, context, data); + member(action: 'update_rank'| 'update_unit' | 'suspension_added' | 'suspension_removed' | 'discharged', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('member', action, context, data, options); } - roles(action: 'add_member' | 'remove_member' | 'create' | 'delete', context: AuditContext, data: any = {}) { - return this.record('roles', action, context, data); + roles(action: 'add_member' | 'remove_member' | 'create' | 'delete', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('roles', action, context, data, options); } - leaveOfAbsence(action: 'created' | 'admin_created' | 'ended' | 'admin_ended' | 'extended', context: AuditContext, data: any = {}) { - return this.record('leave_of_absence', action, context, data); + leaveOfAbsence(action: 'created' | 'admin_created' | 'ended' | 'admin_ended' | 'extended', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('leave_of_absence', action, context, data, options); } - calendar(action: 'event_created' | 'event_updated' | 'attendance_set' | 'cancelled' | 'un-cancelled', context: AuditContext, data: any = {}) { - return this.record('calendar', action, context, data); + calendar(action: 'event_created' | 'event_updated' | 'attendance_set' | 'cancelled' | 'un-cancelled', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('calendar', action, context, data, options); } - application(action: 'created' | 'approved' | 'denied' | 'restarted', context: AuditContext, data: any = {}) { - return this.record('application', action, context, data); + application(action: 'created' | 'approved' | 'denied' | 'restarted', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('application', action, context, data, options); } - course(action: 'report_created' | 'report_edited', context: AuditContext, data: any = {}) { - return this.record('course', action, context, data); + course(action: 'report_created' | 'report_edited', context: AuditContext, data: any = {}, options: AuditRecordOptions = {}) { + return this.record('course', action, context, data, options); } } diff --git a/ui/src/components/trainingReport/trainingReportForm.vue b/ui/src/components/trainingReport/trainingReportForm.vue index 5c13296..13ceffa 100644 --- a/ui/src/components/trainingReport/trainingReportForm.vue +++ b/ui/src/components/trainingReport/trainingReportForm.vue @@ -4,7 +4,7 @@ import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from ' import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' -import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport' +import { getAllAttendeeRoles, getAllTrainings, postTrainingReport, putTrainingReport } from '@/api/trainingReport' import { getAllLightMembers, getLightMembers, getMembers } from '@/api/member' import { Member, MemberLight } from '@shared/types/member' @@ -28,6 +28,40 @@ import Combobox from '../ui/combobox/Combobox.vue' import Tooltip from '../tooltip/Tooltip.vue' import Spinner from '../ui/spinner/Spinner.vue' +const props = withDefaults(defineProps<{ + mode?: 'create' | 'edit'; + report?: CourseEventDetails | null; + reportId?: number | null; +}>(), { + mode: 'create', + report: null, + reportId: null, +}) + +const emit = defineEmits(['submit']) + +function toFormDate(dateValue: Date | string | null | undefined): string { + if (!dateValue) return ""; + const d = new Date(dateValue); + if (isNaN(d.getTime())) return ""; + return d.toISOString().split('T')[0]; +} + +function toFormValues(report: CourseEventDetails) { + return { + course_id: report.course_id, + event_date: toFormDate(report.event_date), + remarks: report.remarks ?? "", + attendees: (report.attendees ?? []).map((attendee) => ({ + attendee_id: attendee.attendee_id, + attendee_role_id: attendee.attendee_role_id, + passed_bookwork: !!attendee.passed_bookwork, + passed_qual: !!attendee.passed_qual, + remarks: attendee.remarks ?? "", + })), + }; +} + const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ validationSchema: toTypedSchema(trainingReportSchema), @@ -40,6 +74,53 @@ const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ } }) +const hydratedReportId = ref(null); +const comboboxRenderEpoch = ref(0); + +watch(() => props.report, (report) => { + hydratedReportId.value = null; + + if (!report) { + if (props.mode === 'create') { + resetForm(); + comboboxRenderEpoch.value++; + } + return; + } + + resetForm({ + values: toFormValues(report), + }); + + comboboxRenderEpoch.value++; +}, { immediate: true }); + +function hydrateEditValuesWhenOptionsReady() { + if (props.mode !== 'edit' || !props.report) { + return; + } + + if (!trainings.value || !members.value || !eventRoles.value) { + return; + } + + const reportId = Number(props.report.id); + if (!Number.isFinite(reportId)) { + return; + } + + if (hydratedReportId.value === reportId) { + return; + } + + resetForm({ + values: toFormValues(props.report), + }); + + hydratedReportId.value = reportId; + comboboxRenderEpoch.value++; +} + // watch(errors, (newErrors) => { // console.log(newErrors) // }, { deep: true }) @@ -79,9 +160,19 @@ async function onSubmit(vals) { event_date: new Date(vals.event_date), } - await postTrainingReport(clean).then((newID) => { - emit("submit", newID); - }); + if (props.mode === 'edit') { + const id = Number(props.reportId); + if (!id || Number.isNaN(id)) { + throw new Error('Cannot edit report without a valid reportId'); + } + + await putTrainingReport(id, clean); + emit("submit", id); + } else { + await postTrainingReport(clean).then((newID) => { + emit("submit", newID); + }); + } } catch (err) { console.error("There was an error submitting the training report", err); } finally { @@ -97,14 +188,18 @@ const trainings = ref(null); const members = ref(null); const eventRoles = ref(null); -const emit = defineEmits(['submit']) - onMounted(async () => { trainings.value = await getAllTrainings(); members.value = await getAllLightMembers(); eventRoles.value = await getAllAttendeeRoles(); + + hydrateEditValuesWhenOptionsReady(); }) +watch([trainings, members, eventRoles, () => props.report], () => { + hydrateEditValuesWhenOptionsReady(); +}); + const selectCourse = ref(false); const openMap = reactive>({}) @@ -143,6 +238,7 @@ const filteredMembers = computed(() => { Training Course @@ -249,6 +345,7 @@ const filteredMembers = computed(() => {
{ openMap['member-' + field.key] = true; memberSearch = memberMap[f.value] }" placeholder="Search members..." class="w-full pl-3" - :display-value="(id) => memberMap[id] || ''" + :display-value="(id) => memberMap[String(id)] || ''" @input="memberSearch = $event.target.value" /> @@ -290,6 +387,7 @@ const filteredMembers = computed(() => {
@@ -410,9 +508,9 @@ const filteredMembers = computed(() => {
diff --git a/ui/src/pages/TrainingReport.vue b/ui/src/pages/TrainingReport.vue index 67ff981..8bf80dc 100644 --- a/ui/src/pages/TrainingReport.vue +++ b/ui/src/pages/TrainingReport.vue @@ -34,16 +34,19 @@ import { } from '@/components/ui/pagination' import Tooltip from '@/components/tooltip/Tooltip.vue'; import { CopyLink } from '@/lib/copyLink'; +import { useUserStore } from '@/stores/user'; -enum sidePanelState { view, create, closed }; +enum sidePanelState { view, create, edit, closed }; const trainingReports = ref(null); const loaded = ref(false); const route = useRoute(); const router = useRouter(); +const user = useUserStore(); const sidePanel = computed(() => { + if (route.path.endsWith('/edit')) return sidePanelState.edit; if (route.path.endsWith('/new')) return sidePanelState.create; if (route.params.id) return sidePanelState.view; return sidePanelState.closed; @@ -110,6 +113,17 @@ async function openTrainingReport(id: number) { router.push(`/trainingReport/${id}`); } +async function openEditPanel() { + if (!focusedTrainingReport.value) return; + + if (isMobile.value) { + mobilePanel.value = sidePanelState.edit; + return; + } + + router.push(`/trainingReport/${focusedTrainingReport.value.id}/edit`); +} + function openCreatePanel() { if (isMobile.value) { mobilePanel.value = sidePanelState.create; @@ -132,6 +146,17 @@ async function closePanel() { await closeTrainingReport(); } +const canEditFocusedReport = computed(() => { + const report = focusedTrainingReport.value; + if (!report) return false; + + const actorId = user.user?.member?.member_id; + const isAuthor = !!actorId && report.created_by === actorId; + const isAdmin = user.hasRole('17th Administrator'); + + return isAuthor || isAdmin; +}); + const sortMode = ref("descending"); const searchString = ref(""); let debounceTimer: ReturnType | null = null; @@ -328,6 +353,9 @@ const expanded = ref(null);

Training Report Details

+ @@ -705,5 +733,33 @@ const expanded = ref(null);
+
+
+
+

Edit Training Report

+
+ + +
+
+ +
+
diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts index ae2d21d..0fcb475 100644 --- a/ui/src/router/index.ts +++ b/ui/src/router/index.ts @@ -26,6 +26,7 @@ const router = createRouter({ { path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, + { path: '/trainingReport/:id/edit', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/developer', component: () => import('@/pages/DeveloperTools.vue'), meta: { requiresAuth: true, memberOnly: true, roles: ['Dev'] } },