added searching and sorting system

This commit is contained in:
2025-11-22 11:32:51 -05:00
parent 9f2948ac18
commit 712941458a
16 changed files with 452 additions and 16 deletions

View File

@@ -26,8 +26,23 @@ courseRouter.get('/roles', async (req, res) => {
})
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();
let events = await getCourseEvents(sortDir, search);
res.status(200).json(events);
} catch (error) {
console.error('failed to fetch reports', error);

View File

@@ -107,7 +107,18 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise<numb
}
}
export async function getCourseEvents(): Promise<CourseEventSummary[]> {
export async function getCourseEvents(sortDir: string, search: string = ""): Promise<CourseEventSummary[]> {
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,
@@ -120,8 +131,12 @@ export async function getCourseEvents(): Promise<CourseEventSummary[]> {
LEFT JOIN courses AS C
ON E.course_id = C.id
LEFT JOIN members AS M
ON E.created_by = M.id;`;
let events: CourseEventSummary[] = await pool.query(sql);
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;
}

View File

@@ -3,8 +3,8 @@ import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } fr
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getTrainingReports(): Promise<CourseEventSummary[]> {
const res = await fetch(`${addr}/courseEvent`);
export async function getTrainingReports(sortMode: string, search: string): Promise<CourseEventSummary[]> {
const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`);
if (res.ok) {
return await res.json() as Promise<CourseEventSummary[]>;

View File

@@ -0,0 +1,26 @@
<script setup>
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: null, required: false },
modelValue: { type: null, required: false },
by: { type: [String, Function], required: false },
dir: { type: String, required: false },
multiple: { type: Boolean, required: false },
autocomplete: { type: String, required: false },
disabled: { type: Boolean, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
});
const emits = defineEmits(["update:modelValue", "update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
position: { type: String, required: false, default: "popper" },
bodyLock: { type: Boolean, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false },
sideFlip: { type: Boolean, required: false },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
alignFlip: { type: Boolean, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"closeAutoFocus",
"escapeKeyDown",
"pointerDownOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { SelectGroup } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectGroup data-slot="select-group" v-bind="props">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { SelectItemText } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import { SelectLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronUp } from "lucide-vue-next";
import { SelectScrollUpButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SelectSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
size: { type: String, required: false, default: "default" },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { SelectValue } from "reka-ui";
const props = defineProps({
placeholder: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";

View File

@@ -10,11 +10,17 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, X } from 'lucide-vue-next';
import { ArrowUpDown, Funnel, Plus, Search, X } from 'lucide-vue-next';
import Button from '@/components/ui/button/Button.vue';
import TrainingReportForm from '@/components/trainingReport/trainingReportForm.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
import { useRoute, useRouter } from 'vue-router';
import Select from '@/components/ui/select/Select.vue';
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue';
import SelectValue from '@/components/ui/select/SelectValue.vue';
import SelectContent from '@/components/ui/select/SelectContent.vue';
import SelectItem from '@/components/ui/select/SelectItem.vue';
import Input from '@/components/ui/input/Input.vue';
enum sidePanelState { view, create, closed };
@@ -60,8 +66,24 @@ async function closeTrainingReport() {
focusedTrainingReport.value = null;
}
const sortMode = ref<string>("descending");
const searchString = ref<string>("");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchString, (newValue) => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
loadTrainingReports();
}, 300); // 300ms debounce
});
watch(() => sortMode.value, async (newSortMode) => {
loadTrainingReports();
})
async function loadTrainingReports() {
trainingReports.value = await getTrainingReports();
trainingReports.value = await getTrainingReports(sortMode.value, searchString.value);
}
onMounted(async () => {
@@ -69,7 +91,6 @@ onMounted(async () => {
if (route.params.id)
viewTrainingReport(Number(route.params.id))
loaded.value = true;
console.log("load")
})
</script>
@@ -77,12 +98,41 @@ onMounted(async () => {
<div class="px-20 mx-auto max-w-[100rem] flex mt-5">
<!-- training report list -->
<div class="px-4 my-3" :class="sidePanel == sidePanelState.closed ? 'w-full' : 'w-2/5'">
<div class="flex justify-between">
<div class="flex justify-between mb-4">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Reports</p>
<Button @click="router.push('/trainingReport/new')">
<Plus></Plus> New Training Report
</Button>
</div>
<!-- search/filter -->
<div class="flex justify-between">
<!-- <Search></Search>
<Funnel></Funnel> -->
<div></div>
<div class="flex flex-row gap-5">
<div>
<label class="text-muted-foreground">Search</label>
<Input v-model="searchString"></Input>
</div>
<div class="flex flex-col">
<label class="text-muted-foreground">Sort By</label>
<Select v-model="sortMode">
<SelectTrigger>
<SelectValue placeholder="Sort By" />
<!-- <ArrowUpDown></ArrowUpDown> -->
</SelectTrigger>
<SelectContent>
<SelectItem value="ascending">
Date (Ascending)
</SelectItem>
<SelectItem value="descending">
Date (Descending)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed">
<Table>
<TableHeader class="">
@@ -120,12 +170,13 @@ onMounted(async () => {
</div>
<div class="max-h-[70vh] overflow-auto scrollbar-themed my-5">
<div class="flex flex-col mb-5 border rounded-lg bg-muted/70 p-2 py-3 px-4">
<p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }}</p>
<p class="scroll-m-20 text-xl font-semibold tracking-tight">{{ focusedTrainingReport.course_name }}
</p>
<div class="flex gap-10">
<p class="text-muted-foreground">{{ focusedTrainingReport.event_date }}</p>
<p class="">Created by {{ focusedTrainingReport.created_by_name === null ? "Unknown User" :
focusedTrainingReport.created_by_name
}}
}}
</p>
</div>
</div>
@@ -143,7 +194,9 @@ onMounted(async () => {
<p>{{ person.attendee_name }}</p>
<p class="">{{ person.role.name }}</p>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">{{ person.remarks == "" ? '--'
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
@@ -168,7 +221,9 @@ onMounted(async () => {
:model-value="person.passed_qual" class="pointer-events-none ml-1">
</Checkbox>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">{{ person.remarks == "" ? '--'
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
@@ -195,7 +250,9 @@ onMounted(async () => {
<div></div>
<div></div>
<p class="col-span-2 text-right px-2"
:class="person.remarks == '' ? 'text-muted-foreground' : ''">{{ person.remarks == "" ? '--'
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
{{ person.remarks == "" ?
'--'
: person.remarks }}</p>
</div>
</div>
@@ -219,7 +276,8 @@ onMounted(async () => {
</div>
<div class="overflow-y-auto max-h-[70vh] mt-5 scrollbar-themed">
<TrainingReportForm class="w-full pl-2"
@submit="(newID) => { router.push(`/trainingReport/${newID}`); loadTrainingReports() }"></TrainingReportForm>
@submit="(newID) => { router.push(`/trainingReport/${newID}`); loadTrainingReports() }">
</TrainingReportForm>
</div>
</div>
</div>