diff --git a/api/src/index.js b/api/src/index.js index cb7629c..59c1381 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -45,6 +45,7 @@ const loaHandler = require('./routes/loa') const { status, memberStatus } = require('./routes/statuses') const authRouter = require('./routes/auth') const { roles, memberRoles } = require('./routes/roles'); +const { courseRouter, eventRouter } = require('./routes/course'); const morgan = require('morgan'); app.use('/application', applicationsRouter); @@ -56,6 +57,8 @@ app.use('/status', status) app.use('/memberStatus', memberStatus) app.use('/roles', roles) app.use('/memberRoles', memberRoles) +app.use('/course', courseRouter) +app.use('/courseEvent', eventRouter) app.use('/', authRouter) app.get('/ping', (req, res) => { diff --git a/api/src/routes/course.ts b/api/src/routes/course.ts new file mode 100644 index 0000000..38bd053 --- /dev/null +++ b/api/src/routes/course.ts @@ -0,0 +1,88 @@ +import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course"; +import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce"; +import { Request, Response, Router } from "express"; + +const courseRouter = Router(); +const eventRouter = Router(); + +courseRouter.get('/', async (req, res) => { + try { + const courses = await getAllCourses(); + res.status(200).json(courses); + } catch (err) { + console.error('failed to fetch courses', err); + res.status(500).json('failed to fetch courses\n' + err); + } +}) + +courseRouter.get('/roles', async (req, res) => { + try { + const roles = await getCourseEventRoles(); + res.status(200).json(roles); + } catch (err) { + console.error('failed to fetch course roles', err); + res.status(500).json('failed to fetch course roles\n' + err); + } +}) + +eventRouter.get('/', async (req: Request, res: Response) => { + const allowedSorts = new Map([ + ["ascending", "ASC"], + ["descending", "DESC"] + ]); + + const sort = String(req.query.sort || "").toLowerCase(); + const search = String(req.query.search || "").toLowerCase(); + if (!allowedSorts.has(sort)) { + return res.status(400).json({ + message: `Invalid sort direction '${req.query.sort}'. Allowed values are 'ascending' or 'descending'.` + }); + } + + const sortDir = allowedSorts.get(sort); + + try { + let events = await getCourseEvents(sortDir, search); + res.status(200).json(events); + } catch (error) { + console.error('failed to fetch reports', error); + res.status(500).json(error); + } +}); + +eventRouter.get('/:id', async (req: Request, res: Response) => { + try { + let out = await getCourseEventDetails(Number(req.params.id)); + res.status(200).json(out); + } catch (error) { + console.error('failed to fetch report', error); + res.status(500).json(error); + } +}); + +eventRouter.get('/attendees/:id', async (req: Request, res: Response) => { + try { + const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id)); + res.status(200).json(attendees); + } catch (err) { + console.error('failed to fetch attendees', err); + res.status(500).json("failed to fetch attendees\n" + err); + } +}) + +eventRouter.post('/', async (req: Request, res: Response) => { + const posterID: number = req.user.id; + try { + console.log(); + let data: CourseEventDetails = req.body; + data.created_by = posterID; + const id = await insertCourseEvent(data); + res.status(201).json(id); + } catch (error) { + console.error('failed to post training', error); + res.status(500).json("failed to post training\n" + error) + } +}) + +module.exports.courseRouter = courseRouter; +module.exports.eventRouter = eventRouter; \ No newline at end of file diff --git a/api/src/services/CourseSerivce.ts b/api/src/services/CourseSerivce.ts new file mode 100644 index 0000000..6b43c32 --- /dev/null +++ b/api/src/services/CourseSerivce.ts @@ -0,0 +1,147 @@ +import pool from "../db" +import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course" + +export async function getAllCourses(): Promise { + const sql = "SELECT * FROM courses WHERE deleted = false;" + + const res: Course[] = await pool.query(sql); + + return res; +} + +export async function getCourseByID(id: number): Promise { + const sql = "SELECT * FROM courses WHERE id = ?;" + const res: Course[] = await pool.query(sql, [id]); + return res[0]; +} + +function buildAttendee(row: RawAttendeeRow): CourseAttendee { + return { + passed_bookwork: !!row.passed_bookwork, + passed_qual: !!row.passed_qual, + attendee_id: row.attendee_id, + course_event_id: row.course_event_id, + created_at: row.created_at, + updated_at: row.updated_at, + remarks: row.remarks, + attendee_role_id: row.attendee_role_id, + attendee_name: row.attendee_name, + role: row.role_id + ? { + id: row.role_id, + name: row.role_name, + description: row.role_description, + deleted: !!row.role_deleted, + created_at: row.role_created_at, + updated_at: row.role_updated_at, + } + : null + }; +} + +export async function getCourseEventAttendees(id: number): Promise { + const sql = `SELECT + ca.*, + mem.name AS attendee_name, + ar.id AS role_id, + ar.name AS role_name, + ar.description AS role_description, + ar.deleted AS role_deleted, + ar.created_at AS role_created_at, + ar.updated_at AS role_updated_at + FROM course_attendees ca + LEFT JOIN course_attendee_roles ar ON ar.id = ca.attendee_role_id + LEFT JOIN members mem ON ca.attendee_id = mem.id + WHERE ca.course_event_id = ?;`; + + const res: RawAttendeeRow[] = await pool.query(sql, [id]); + + return res.map((row) => buildAttendee(row)) +} + +export async function getCourseEventDetails(id: number): Promise { + const sql = `SELECT + E.*, + M.name AS created_by_name, + C.name AS course_name + FROM course_events AS E + LEFT JOIN courses AS C + ON E.course_id = C.id + LEFT JOIN members AS M + ON E.created_by = M.id + WHERE E.id = ?; + `; + let rows: CourseEventDetails[] = await pool.query(sql, [id]); + let event = rows[0]; + event.attendees = await getCourseEventAttendees(id); + event.course = await getCourseByID(event.course_id); + return event; +} + +export async function insertCourseEvent(event: CourseEventDetails): Promise { + console.log(event); + const con = await pool.getConnection(); + try { + await con.beginTransaction(); + const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by) VALUES (?, ?, ?, ?);", [event.course_id, event.event_date, event.remarks, event.created_by]); + var eventID: number = res.insertId; + + for (const attendee of event.attendees) { + await con.query(`INSERT INTO course_attendees ( + attendee_id, + course_event_id, + attendee_role_id, + passed_bookwork, + passed_qual, + remarks + ) + VALUES (?, ?, ?, ?, ?, ?);`, [attendee.attendee_id, eventID, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks]); + } + await con.commit(); + await con.release(); + return Number(eventID); + } catch (error) { + await con.rollback(); + await con.release(); + throw error; + } +} + +export async function getCourseEvents(sortDir: string, search: string = ""): Promise { + + let params = []; + let searchString = ""; + if (search !== "") { + searchString = `WHERE (C.name LIKE ? OR + C.short_name LIKE ? OR + M.name LIKE ?) `; + const p = `%${search}%`; + params.push(p, p, p); + } + + const sql = `SELECT + E.id AS event_id, + E.course_id, + E.event_date AS date, + E.created_by, + C.name AS course_name, + C.short_name AS course_shortname, + M.name AS created_by_name + FROM course_events AS E + LEFT JOIN courses AS C + ON E.course_id = C.id + LEFT JOIN members AS M + ON E.created_by = M.id + ${searchString} + ORDER BY E.event_date ${sortDir};`; + console.log(sql) + console.log(params) + let events: CourseEventSummary[] = await pool.query(sql, params); + return events; +} + +export async function getCourseEventRoles(): Promise { + const sql = "SELECT * FROM course_attendee_roles;" + const roles: CourseAttendeeRole[] = await pool.query(sql); + return roles; +} \ No newline at end of file diff --git a/api/src/services/memberService.ts b/api/src/services/memberService.ts index 98045da..4563a6a 100644 --- a/api/src/services/memberService.ts +++ b/api/src/services/memberService.ts @@ -20,4 +20,15 @@ export async function setUserState(userID: number, state: MemberState) { SET state = ? WHERE id = ?;`; return await pool.query(sql, [state, userID]); -} \ No newline at end of file +} + +declare global { + namespace Express { + interface Request { + user: { + id: number; + name: string; + }; + } + } +} diff --git a/shared/package-lock.json b/shared/package-lock.json new file mode 100644 index 0000000..91148cb --- /dev/null +++ b/shared/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "@app/shared", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@app/shared", + "version": "1.0.0", + "dependencies": { + "zod": "^3.25.76" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/shared/package.json b/shared/package.json index 374484e..1f2adda 100644 --- a/shared/package.json +++ b/shared/package.json @@ -2,5 +2,8 @@ "name": "@app/shared", "version": "1.0.0", "main": "index.ts", - "type": "module" -} \ No newline at end of file + "type": "module", + "dependencies": { + "zod": "^3.25.76" + } +} diff --git a/shared/schemas/trainingReportSchema.ts b/shared/schemas/trainingReportSchema.ts new file mode 100644 index 0000000..5bfb1fd --- /dev/null +++ b/shared/schemas/trainingReportSchema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const courseEventAttendeeSchema = z.object({ + attendee_id: z.number({invalid_type_error: "Must select a member"}).int().positive(), + passed_bookwork: z.boolean(), + passed_qual: z.boolean(), + remarks: z.string(), + attendee_role_id: z.number({invalid_type_error: "Must select a role"}).int().positive() +}) + +export const trainingReportSchema = z.object({ + id: z.number().int().positive().optional(), + course_id: z.number({ invalid_type_error: "Must select a training" }).int(), + event_date: z + .string() + .refine( + (val) => !isNaN(Date.parse(val)), + "Must be a valid date" + ), + remarks: z.string().nullable().optional(), + attendees: z.array(courseEventAttendeeSchema).default([]), +}) + diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..3f94d22 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["./**/*.ts"] +} diff --git a/shared/types/course.ts b/shared/types/course.ts new file mode 100644 index 0000000..8ae21f0 --- /dev/null +++ b/shared/types/course.ts @@ -0,0 +1,91 @@ +export interface Course { + id: number; + name: string; + short_name: string; + category: string; + description?: string | null; + image_url?: string | null; + created_at: string; + updated_at: string; + deleted?: number | boolean; + prereq_id?: number | null; + hasBookwork: boolean; + hasQual: boolean; +} + +export interface CourseEventDetails { + id: number | null; // PK + course_id: number | null; // FK → courses.id + event_type: number | null; // FK → event_types.id + event_date: string; // datetime (not nullable) + + guilded_event_id: number | null; + + created_at: string; // datetime + updated_at: string; // datetime + + deleted: boolean | null; // tinyint(4), nullable + report_url: string | null; // varchar(2048) + remarks: string | null; // text + + attendees: CourseAttendee[] | null; + + created_by: number | null; + created_by_name: string | null; + course_name: string | null; + course: Course | null; +} + + +export interface CourseAttendee { + passed_bookwork: boolean; // tinyint(1) + passed_qual: boolean; // tinyint(1) + attendee_id: number; // PK + course_event_id: number; // PK + attendee_role_id: number | null; + role: CourseAttendeeRole | null; + created_at: string; // datetime → ISO string + updated_at: string; // datetime → ISO string + remarks: string | null; + + attendee_name: string | null; +} + +export interface CourseAttendeeRole { + id: number; // PK, auto-increment + name: string | null; // varchar(50), unique, nullable + description: string | null; // text + created_at: string | null; // datetime (nullable) + updated_at: string | null; // datetime (nullable) + deleted: boolean; // tinyint(4) +} + +export interface RawAttendeeRow { + passed_bookwork: number; // tinyint(1) + passed_qual: number; // tinyint(1) + attendee_id: number; + course_event_id: number; + attendee_role_id: number | null; + created_at: string; + updated_at: string; + remarks: string | null; + + role_id: number | null; + role_name: string | null; + role_description: string | null; + role_deleted: number | null; + role_created_at: string | null; + role_updated_at: string | null; + + attendee_name: string | null; +} + +export interface CourseEventSummary { + event_id: number; + course_id: number; + course_name: string; + course_shortname: string; + date: string; + created_by: number; + created_by_name: string; +} \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index da4cbea..46c81a2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -21,7 +21,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.539.0", "pinia": "^3.0.3", - "reka-ui": "^2.5.0", + "reka-ui": "^2.6.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", @@ -3235,9 +3235,9 @@ } }, "node_modules/reka-ui": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz", - "integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz", + "integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/ui/package.json b/ui/package.json index 9771d5d..f83bc55 100644 --- a/ui/package.json +++ b/ui/package.json @@ -25,7 +25,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.539.0", "pinia": "^3.0.3", - "reka-ui": "^2.5.0", + "reka-ui": "^2.6.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", diff --git a/ui/src/api/trainingReport.ts b/ui/src/api/trainingReport.ts new file mode 100644 index 0000000..a7a04b3 --- /dev/null +++ b/ui/src/api/trainingReport.ts @@ -0,0 +1,66 @@ +import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course' + +//@ts-ignore +const addr = import.meta.env.VITE_APIHOST; + +export async function getTrainingReports(sortMode: string, search: string): Promise { + const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`); + + if (res.ok) { + return await res.json() as Promise; + } else { + console.error("Something went wrong"); + throw new Error("Failed to load training reports"); + } +} + +export async function getTrainingReport(id: number): Promise { + const res = await fetch(`${addr}/courseEvent/${id}`); + + if (res.ok) { + return await res.json() as Promise; + } else { + console.error("Something went wrong"); + throw new Error("Failed to load training reports"); + } +} + +export async function getAllTrainings(): Promise { + const res = await fetch(`${addr}/course`); + + if (res.ok) { + return await res.json() as Promise; + } else { + console.error("Something went wrong"); + throw new Error("Failed to load training list"); + } +} + +export async function getAllAttendeeRoles(): Promise { + const res = await fetch(`${addr}/course/roles`); + + if (res.ok) { + return await res.json() as Promise; + } else { + console.error("Something went wrong"); + throw new Error("Failed to load attendee roles"); + } +} + +export async function postTrainingReport(report: CourseEventDetails) { + const res = await fetch(`${addr}/courseEvent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(report), + credentials: 'include', + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to post training report: ${res.status} ${errorText}`); + } + + return res.json(); // expected to return the inserted report or new ID +} diff --git a/ui/src/components/trainingReport/trainingReportForm.vue b/ui/src/components/trainingReport/trainingReportForm.vue new file mode 100644 index 0000000..06c529b --- /dev/null +++ b/ui/src/components/trainingReport/trainingReportForm.vue @@ -0,0 +1,269 @@ + +