diff --git a/api/src/routes/course.ts b/api/src/routes/course.ts index f04ea08..7b10bdd 100644 --- a/api/src/routes/course.ts +++ b/api/src/routes/course.ts @@ -33,23 +33,26 @@ cr.get('/roles', async (req, res) => { }) er.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); + const allowedSorts = new Map([ + ["ascending", "ASC"], + ["descending", "DESC"] + ]); + + const page = Number(req.query.page) || undefined; + const pageSize = Number(req.query.pageSize) || undefined; + + 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); + + let events = await getCourseEvents(sortDir, search, page, pageSize); res.status(200).json(events); } catch (error) { console.error('failed to fetch reports', error); diff --git a/api/src/routes/members.ts b/api/src/routes/members.ts index 3e574aa..026424d 100644 --- a/api/src/routes/members.ts +++ b/api/src/routes/members.ts @@ -5,7 +5,7 @@ import { Request, Response } from 'express'; import pool from '../db'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { getUserActiveLOA } from '../services/loaService'; -import { getMemberSettings, getMembersFull, getMembersLite, getUserData, setUserSettings } from '../services/memberService'; +import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, setUserSettings } from '../services/memberService'; import { getUserRoles } from '../services/rolesService'; import { memberSettings, MemberState } from '@app/shared/types/member'; @@ -75,6 +75,16 @@ router.put('/settings', [requireLogin], async (req: Request, res: Response) => { } }) +router.get('/lite', [requireLogin], async (req: Request, res: Response) => { + try { + let out = await getAllMembersLite(); + res.status(200).json(out); + } catch (error) { + console.error(error); + res.status(500).json(error); + } +}) + router.post('/lite/bulk', async (req: Request, res: Response) => { try { let ids = req.body.ids; @@ -86,6 +96,7 @@ router.post('/lite/bulk', async (req: Request, res: Response) => { } }) + router.post('/full/bulk', async (req: Request, res: Response) => { try { let ids = req.body.ids; diff --git a/api/src/services/CourseSerivce.ts b/api/src/services/CourseSerivce.ts index f365da6..8b2f61df 100644 --- a/api/src/services/CourseSerivce.ts +++ b/api/src/services/CourseSerivce.ts @@ -1,5 +1,6 @@ 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"; export async function getAllCourses(): Promise { const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;" @@ -107,7 +108,8 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise { +export async function getCourseEvents(sortDir: string, search: string = "", page = 1, pageSize = 10): Promise> { + const offset = (page - 1) * pageSize; let params = []; let searchString = ""; @@ -133,11 +135,23 @@ export async function getCourseEvents(sortDir: string, search: string = ""): Pro 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; + ORDER BY E.event_date ${sortDir} + LIMIT ? OFFSET ?;`; + + let countSQL = `SELECT COUNT(*) AS count + 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};` + let recordCount = Number((await pool.query(countSQL, [...params]))[0].count); + let pageCount = recordCount / pageSize; + + let events: CourseEventSummary[] = await pool.query(sql, [...params, pageSize, offset]); + + let output: PagedData = { data: events, pagination: { page: page, pageSize: pageSize, total: recordCount, totalPages: pageCount } } + + return output; } export async function getCourseEventRoles(): Promise { diff --git a/api/src/services/memberService.ts b/api/src/services/memberService.ts index 6381ad1..b639b21 100644 --- a/api/src/services/memberService.ts +++ b/api/src/services/memberService.ts @@ -49,6 +49,18 @@ export async function getMembersLite(ids: number[]): Promise { return res; } +export async function getAllMembersLite(): Promise { + const sql = `SELECT m.member_id AS id, + m.member_name AS username, + m.displayName, + u.color + FROM view_member_rank_unit_status_latest m + LEFT JOIN units u ON u.name = m.unit;`; + + const res: MemberLight[] = await pool.query(sql); + return res; +} + export async function getMembersFull(ids: number[]): Promise { const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`; const res: Member[] = await pool.query(sql, [ids]); diff --git a/ui/src/api/member.ts b/ui/src/api/member.ts index 9fe2935..327a5ce 100644 --- a/ui/src/api/member.ts +++ b/ui/src/api/member.ts @@ -38,6 +38,20 @@ export async function setMemberSettings(settings: memberSettings) { return; } +export async function getAllLightMembers(): Promise { + const response = await fetch(`${addr}/members/lite`, { + credentials: 'include', + headers: { + "Content-Type": "application/json", + } + }); + + if (!response.ok) { + throw new Error("Failed to fetch light members"); + } + return response.json(); +} + export async function getLightMembers(ids: number[]): Promise { if (ids.length === 0) return []; diff --git a/ui/src/api/trainingReport.ts b/ui/src/api/trainingReport.ts index c1b8adc..a261b7b 100644 --- a/ui/src/api/trainingReport.ts +++ b/ui/src/api/trainingReport.ts @@ -1,15 +1,34 @@ import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course' +import { PagedData } from '@shared/types/pagination'; //@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}`, { +export async function getTrainingReports(sortMode?: string, search?: string, page?: number, pageSize?: number): Promise> { + const params = new URLSearchParams(); + + if (page !== undefined) { + params.set("page", page.toString()); + } + + if (pageSize !== undefined) { + params.set("pageSize", pageSize.toString()); + } + + if (sortMode !== undefined) { + params.set("sort", sortMode.toString()); + } + + if (search !== undefined || search !== "") { + params.set("search", search.toString()); + } + + const res = await fetch(`${addr}/courseEvent?${params}`, { credentials: 'include', }); if (res.ok) { - return await res.json() as Promise; + return await res.json() as Promise>; } else { console.error("Something went wrong"); throw new Error("Failed to load training reports"); @@ -31,7 +50,7 @@ export async function getTrainingReport(id: number): Promise export async function getAllTrainings(): Promise { const res = await fetch(`${addr}/course`, { - credentials: 'include', + credentials: 'include', }); if (res.ok) { diff --git a/ui/src/components/trainingReport/trainingReportForm.vue b/ui/src/components/trainingReport/trainingReportForm.vue index 26ef3c1..b68aeb0 100644 --- a/ui/src/components/trainingReport/trainingReportForm.vue +++ b/ui/src/components/trainingReport/trainingReportForm.vue @@ -3,9 +3,10 @@ import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course' import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport' -import { getMembers, Member } from '@/api/member' +import { getAllLightMembers, getLightMembers, getMembers } from '@/api/member' +import { Member, MemberLight } from '@shared/types/member' import FieldGroup from '../ui/field/FieldGroup.vue' import Field from '../ui/field/Field.vue' @@ -13,12 +14,17 @@ import FieldLabel from '../ui/field/FieldLabel.vue' import FieldError from '../ui/field/FieldError.vue' import Button from '../ui/button/Button.vue' import Textarea from '../ui/textarea/Textarea.vue' -import { Plus, X } from 'lucide-vue-next'; +import { Check, Plus, X } from 'lucide-vue-next'; import FieldSet from '../ui/field/FieldSet.vue' import FieldLegend from '../ui/field/FieldLegend.vue' import FieldDescription from '../ui/field/FieldDescription.vue' import Checkbox from '../ui/checkbox/Checkbox.vue' import { configure } from 'vee-validate' +import { ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox" +import Popover from "@/components/ui/popover/Popover.vue"; +import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue"; +import PopoverContent from "@/components/ui/popover/PopoverContent.vue"; +import Combobox from '../ui/combobox/Combobox.vue' const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ @@ -79,16 +85,44 @@ const { remove, push, fields } = useFieldArray('attendees'); const selectedCourse = computed(() => { return trainings.value?.find(c => c.id == values.course_id) }) const trainings = ref(null); -const members = ref(null); +const members = ref(null); const eventRoles = ref(null); const emit = defineEmits(['submit']) onMounted(async () => { trainings.value = await getAllTrainings(); - members.value = await getMembers(); + members.value = await getAllLightMembers(); eventRoles.value = await getAllAttendeeRoles(); }) + +const selectCourse = ref(false); +const openMap = reactive>({}) + +const memberMap = computed(() => + Object.fromEntries( + members.value?.map(m => [m.id, m.displayName || m.username]) ?? [] + ) +) + +const memberSearch = ref('') + +const MAX_RESULTS = 50 + +const filteredMembers = computed(() => { + const q = memberSearch?.value?.toLowerCase() ?? "" + const results: MemberLight[] = [] + + for (const m of members.value ?? []) { + if (!q || (m.displayName || m.username).toLowerCase().includes(q)) { + results.push(m) + if (results.length >= MAX_RESULTS) break + } + } + + return results +}) +