Training-Report #27
@@ -45,6 +45,7 @@ const loaHandler = require('./routes/loa')
|
|||||||
const { status, memberStatus } = require('./routes/statuses')
|
const { status, memberStatus } = require('./routes/statuses')
|
||||||
const authRouter = require('./routes/auth')
|
const authRouter = require('./routes/auth')
|
||||||
const { roles, memberRoles } = require('./routes/roles');
|
const { roles, memberRoles } = require('./routes/roles');
|
||||||
|
const { courseRouter, eventRouter } = require('./routes/course');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
|
|
||||||
app.use('/application', applicationsRouter);
|
app.use('/application', applicationsRouter);
|
||||||
@@ -56,6 +57,8 @@ app.use('/status', status)
|
|||||||
app.use('/memberStatus', memberStatus)
|
app.use('/memberStatus', memberStatus)
|
||||||
app.use('/roles', roles)
|
app.use('/roles', roles)
|
||||||
app.use('/memberRoles', memberRoles)
|
app.use('/memberRoles', memberRoles)
|
||||||
|
app.use('/course', courseRouter)
|
||||||
|
app.use('/courseEvent', eventRouter)
|
||||||
app.use('/', authRouter)
|
app.use('/', authRouter)
|
||||||
|
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
|
|||||||
88
api/src/routes/course.ts
Normal file
88
api/src/routes/course.ts
Normal file
@@ -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;
|
||||||
147
api/src/services/CourseSerivce.ts
Normal file
147
api/src/services/CourseSerivce.ts
Normal file
@@ -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<Course[]> {
|
||||||
|
const sql = "SELECT * FROM courses WHERE deleted = false;"
|
||||||
|
|
||||||
|
const res: Course[] = await pool.query(sql);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseByID(id: number): Promise<Course> {
|
||||||
|
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<CourseAttendee[]> {
|
||||||
|
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<CourseEventDetails> {
|
||||||
|
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<number> {
|
||||||
|
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<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,
|
||||||
|
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<CourseAttendeeRole[]> {
|
||||||
|
const sql = "SELECT * FROM course_attendee_roles;"
|
||||||
|
const roles: CourseAttendeeRole[] = await pool.query(sql);
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
@@ -20,4 +20,15 @@ export async function setUserState(userID: number, state: MemberState) {
|
|||||||
SET state = ?
|
SET state = ?
|
||||||
WHERE id = ?;`;
|
WHERE id = ?;`;
|
||||||
return await pool.query(sql, [state, userID]);
|
return await pool.query(sql, [state, userID]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
shared/package-lock.json
generated
Normal file
24
shared/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,8 @@
|
|||||||
"name": "@app/shared",
|
"name": "@app/shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"type": "module"
|
"type": "module",
|
||||||
}
|
"dependencies": {
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
23
shared/schemas/trainingReportSchema.ts
Normal file
23
shared/schemas/trainingReportSchema.ts
Normal file
@@ -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([]),
|
||||||
|
})
|
||||||
|
|
||||||
17
shared/tsconfig.json
Normal file
17
shared/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
91
shared/types/course.ts
Normal file
91
shared/types/course.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -21,7 +21,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.539.0",
|
"lucide-vue-next": "^0.539.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
@@ -3235,9 +3235,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.539.0",
|
"lucide-vue-next": "^0.539.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
|
|||||||
66
ui/src/api/trainingReport.ts
Normal file
66
ui/src/api/trainingReport.ts
Normal file
@@ -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<CourseEventSummary[]> {
|
||||||
|
const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json() as Promise<CourseEventSummary[]>;
|
||||||
|
} else {
|
||||||
|
console.error("Something went wrong");
|
||||||
|
throw new Error("Failed to load training reports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrainingReport(id: number): Promise<CourseEventDetails> {
|
||||||
|
const res = await fetch(`${addr}/courseEvent/${id}`);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json() as Promise<CourseEventDetails>;
|
||||||
|
} else {
|
||||||
|
console.error("Something went wrong");
|
||||||
|
throw new Error("Failed to load training reports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllTrainings(): Promise<Course[]> {
|
||||||
|
const res = await fetch(`${addr}/course`);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json() as Promise<Course[]>;
|
||||||
|
} else {
|
||||||
|
console.error("Something went wrong");
|
||||||
|
throw new Error("Failed to load training list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> {
|
||||||
|
const res = await fetch(`${addr}/course/roles`);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json() as Promise<CourseAttendeeRole[]>;
|
||||||
|
} 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
|
||||||
|
}
|
||||||
269
ui/src/components/trainingReport/trainingReportForm.vue
Normal file
269
ui/src/components/trainingReport/trainingReportForm.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
|
||||||
|
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 { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
|
||||||
|
import { getMembers, Member } from '@/api/member'
|
||||||
|
import FieldGroup from '../ui/field/FieldGroup.vue'
|
||||||
|
import Field from '../ui/field/Field.vue'
|
||||||
|
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 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'
|
||||||
|
|
||||||
|
const { handleSubmit, resetForm, errors, values } = useForm({
|
||||||
|
validationSchema: toTypedSchema(trainingReportSchema),
|
||||||
|
initialValues: {
|
||||||
|
course_id: null,
|
||||||
|
event_date: "",
|
||||||
|
remarks: "",
|
||||||
|
attendees: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(errors, (newErrors) => {
|
||||||
|
console.log(newErrors)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(values, (newErrors) => {
|
||||||
|
console.log(newErrors.attendees)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const submitForm = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
function toMySQLDateTime(date: Date): string {
|
||||||
|
return date
|
||||||
|
.toISOString() // 2025-11-19T00:00:00.000Z
|
||||||
|
.slice(0, 23) // keep milliseconds → 2025-11-19T00:00:00.000
|
||||||
|
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function onSubmit(vals) {
|
||||||
|
try {
|
||||||
|
const clean: CourseEventDetails = {
|
||||||
|
...vals,
|
||||||
|
event_date: toMySQLDateTime(new Date(vals.event_date)),
|
||||||
|
}
|
||||||
|
postTrainingReport(clean).then((newID) => {
|
||||||
|
emit("submit", newID);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log("There was an error submitting the training report", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remove, push, fields } = useFieldArray('attendees');
|
||||||
|
|
||||||
|
const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) })
|
||||||
|
|
||||||
|
const trainings = ref<Course[] | null>(null);
|
||||||
|
const members = ref<Member[] | null>(null);
|
||||||
|
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
trainings.value = await getAllTrainings();
|
||||||
|
members.value = await getMembers();
|
||||||
|
eventRoles.value = await getAllAttendeeRoles();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<div class="flex-1">
|
||||||
|
<FieldGroup>
|
||||||
|
<VeeField v-slot="{ field, errors }" name="course_id">
|
||||||
|
<Field :data-invalid="!!errors.length">
|
||||||
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel>
|
||||||
|
<select v-bind="field"
|
||||||
|
class="h-9 border rounded p-2 w-auto focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
||||||
|
<option value="" disabled>Select a course</option>
|
||||||
|
<option v-for="course in trainings" :key="course.id" :value="course.id">
|
||||||
|
{{ course.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="h-4">
|
||||||
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</VeeField>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
<div class="w-[150px]">
|
||||||
|
<FieldGroup>
|
||||||
|
<VeeField v-slot="{ field, errors }" name="event_date">
|
||||||
|
<Field :data-invalid="!!errors.length">
|
||||||
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Event Date</FieldLabel>
|
||||||
|
<input type="date" v-bind="field"
|
||||||
|
class="h-9 border rounded p-2 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none" />
|
||||||
|
<div class="h-4">
|
||||||
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</VeeField>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VeeFieldArray name="attendees" v-slot="{ fields, push, remove }">
|
||||||
|
<FieldSet class="gap-4">
|
||||||
|
<FieldLegend class="scroll-m-20 text-lg tracking-tight">Attendees</FieldLegend>
|
||||||
|
<FieldDescription>Add members who attended this session.</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup class="gap-4">
|
||||||
|
|
||||||
|
<!-- Column Headers -->
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[180px_155px_65px_41px_1fr_auto] gap-3 font-medium text-sm text-muted-foreground">
|
||||||
|
<div>Member</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>Bookwork</div>
|
||||||
|
<div>Qual</div>
|
||||||
|
<div>Remarks</div>
|
||||||
|
<div></div> <!-- empty for remove button -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Attendee Rows -->
|
||||||
|
<template v-for="(field, index) in fields" :key="field.key">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[180px_160px_50px_50px_1fr_auto] gap-3 items-center">
|
||||||
|
|
||||||
|
<!-- Member Select -->
|
||||||
|
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
|
||||||
|
<div>
|
||||||
|
<select v-bind="f"
|
||||||
|
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
||||||
|
<option value="">Select member...</option>
|
||||||
|
<option v-for="m in members" :key="m.member_id" :value="m.member_id">
|
||||||
|
{{ m.member_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="h-4">
|
||||||
|
<FieldError v-if="e.length" :errors="e" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
|
||||||
|
<!-- Role Select -->
|
||||||
|
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
|
||||||
|
<div>
|
||||||
|
<select v-bind="f"
|
||||||
|
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
|
||||||
|
<option value="">Select role...</option>
|
||||||
|
<option v-for="r in eventRoles" :key="r.id" :value="r.id">
|
||||||
|
{{ r.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="h-4">
|
||||||
|
<FieldError v-if="e.length" :errors="e" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
|
||||||
|
<!-- Bookwork Checkbox -->
|
||||||
|
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
|
||||||
|
:value="false" :unchecked-value="true">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="relative inline-flex items-center group">
|
||||||
|
|
||||||
|
<Checkbox :disabled="!selectedCourse?.hasBookwork"
|
||||||
|
:name="`attendees[${index}].passed_bookwork`" v-bind="field">
|
||||||
|
</Checkbox>
|
||||||
|
<!-- Tooltip bubble -->
|
||||||
|
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
|
||||||
|
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
|
||||||
|
text-popover-foreground shadow-md border border-border
|
||||||
|
opacity-0 translate-y-1
|
||||||
|
group-hover:opacity-100 group-hover:translate-y-0
|
||||||
|
transition-opacity transition-transform duration-150">
|
||||||
|
This course does not have bookwork
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
|
||||||
|
<!-- Qual Checkbox -->
|
||||||
|
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
|
||||||
|
:value="false" :unchecked-value="true">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="relative inline-flex items-center group">
|
||||||
|
<Checkbox :disabled="!selectedCourse?.hasQual"
|
||||||
|
:name="`attendees[${index}].passed_qual`" v-bind="field"></Checkbox>
|
||||||
|
<!-- Tooltip bubble -->
|
||||||
|
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
|
||||||
|
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
|
||||||
|
text-popover-foreground shadow-md border border-border
|
||||||
|
opacity-0 translate-y-1
|
||||||
|
group-hover:opacity-100 group-hover:translate-y-0
|
||||||
|
transition-opacity transition-transform duration-150">
|
||||||
|
This course does not have a qualification
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
|
||||||
|
<!-- Remarks -->
|
||||||
|
<VeeField :name="`attendees[${index}].remarks`" v-slot="{ field: f, errors: e }">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<textarea v-bind="f"
|
||||||
|
class="h-[38px] resize-none border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"
|
||||||
|
placeholder="Optional remarks"></textarea>
|
||||||
|
<div class="h-4">
|
||||||
|
<FieldError v-if="e.length" :errors="e" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<!-- Remove button -->
|
||||||
|
<Button type="button" variant="ghost" size="sm" @click="remove(index)"
|
||||||
|
class="self-center">
|
||||||
|
<X :size="10" />
|
||||||
|
</Button>
|
||||||
|
<div class="h-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<Button type="button" size="sm" variant="outline"
|
||||||
|
@click="push({ attendee_id: null, attendee_role_id: null, passed_bookwork: false, passed_qual: false, remarks: '' })">
|
||||||
|
<Plus class="mr-1 h-4 w-4" />
|
||||||
|
Add Attendee
|
||||||
|
</Button>
|
||||||
|
</FieldSet>
|
||||||
|
</VeeFieldArray>
|
||||||
|
|
||||||
|
<FieldGroup class="pt-3">
|
||||||
|
<VeeField v-slot="{ field, errors }" name="remarks">
|
||||||
|
<Field :data-invalid="!!errors.length">
|
||||||
|
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Remarks</FieldLabel>
|
||||||
|
<Textarea v-bind="field" placeholder="Any remarks about this training event..."
|
||||||
|
autocomplete="off" />
|
||||||
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
|
</Field>
|
||||||
|
</VeeField>
|
||||||
|
</FieldGroup>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
|
||||||
|
<Button type="submit" form="trainingForm">Submit</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
20
ui/src/components/ui/field/Field.vue
Normal file
20
ui/src/components/ui/field/Field.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fieldVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
orientation: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
:data-orientation="orientation"
|
||||||
|
:class="cn(fieldVariants({ orientation }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
ui/src/components/ui/field/FieldContent.vue
Normal file
21
ui/src/components/ui/field/FieldContent.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
ui/src/components/ui/field/FieldDescription.vue
Normal file
23
ui/src/components/ui/field/FieldDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||||
|
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
56
ui/src/components/ui/field/FieldError.vue
Normal file
56
ui/src/components/ui/field/FieldError.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
errors: { type: Array, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
if (!props.errors || props.errors.length === 0) return null;
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(
|
||||||
|
props.errors.filter(Boolean).map((error) => {
|
||||||
|
const message = typeof error === "string" ? error : error?.message;
|
||||||
|
return [message, error];
|
||||||
|
}),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
|
||||||
|
return typeof uniqueErrors[0] === "string"
|
||||||
|
? uniqueErrors[0]
|
||||||
|
: uniqueErrors[0].message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueErrors.map((error) =>
|
||||||
|
typeof error === "string" ? error : error?.message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="$slots.default || content"
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-if="$slots.default" />
|
||||||
|
|
||||||
|
<template v-else-if="typeof content === 'string'">
|
||||||
|
{{ content }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-else-if="Array.isArray(content)"
|
||||||
|
class="ml-4 flex list-disc flex-col gap-1"
|
||||||
|
>
|
||||||
|
<li v-for="(error, index) in content" :key="index">
|
||||||
|
{{ error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
ui/src/components/ui/field/FieldGroup.vue
Normal file
21
ui/src/components/ui/field/FieldGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
ui/src/components/ui/field/FieldLabel.vue
Normal file
24
ui/src/components/ui/field/FieldLabel.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||||
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||||
|
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
25
ui/src/components/ui/field/FieldLegend.vue
Normal file
25
ui/src/components/ui/field/FieldLegend.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
variant: { type: String, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'mb-3 font-medium',
|
||||||
|
'data-[variant=legend]:text-base',
|
||||||
|
'data-[variant=label]:text-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</legend>
|
||||||
|
</template>
|
||||||
30
ui/src/components/ui/field/FieldSeparator.vue
Normal file
30
ui/src/components/ui/field/FieldSeparator.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
:data-content="!!$slots.default"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Separator class="absolute inset-0 top-1/2" />
|
||||||
|
<span
|
||||||
|
v-if="$slots.default"
|
||||||
|
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
ui/src/components/ui/field/FieldSet.vue
Normal file
22
ui/src/components/ui/field/FieldSet.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
21
ui/src/components/ui/field/FieldTitle.vue
Normal file
21
ui/src/components/ui/field/FieldTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
ui/src/components/ui/field/index.js
Normal file
36
ui/src/components/ui/field/index.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export { default as Field } from "./Field.vue";
|
||||||
|
export { default as FieldContent } from "./FieldContent.vue";
|
||||||
|
export { default as FieldDescription } from "./FieldDescription.vue";
|
||||||
|
export { default as FieldError } from "./FieldError.vue";
|
||||||
|
export { default as FieldGroup } from "./FieldGroup.vue";
|
||||||
|
export { default as FieldLabel } from "./FieldLabel.vue";
|
||||||
|
export { default as FieldLegend } from "./FieldLegend.vue";
|
||||||
|
export { default as FieldSeparator } from "./FieldSeparator.vue";
|
||||||
|
export { default as FieldSet } from "./FieldSet.vue";
|
||||||
|
export { default as FieldTitle } from "./FieldTitle.vue";
|
||||||
36
ui/src/components/ui/input-group/InputGroup.vue
Normal file
36
ui/src/components/ui/input-group/InputGroup.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||||
|
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||||
|
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||||
|
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||||
|
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
32
ui/src/components/ui/input-group/InputGroupAddon.vue
Normal file
32
ui/src/components/ui/input-group/InputGroupAddon.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { inputGroupAddonVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
align: { type: null, required: false, default: "inline-start" },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInputGroupAddonClick(e) {
|
||||||
|
const currentTarget = e.currentTarget;
|
||||||
|
const target = e.target;
|
||||||
|
if (target && target.closest("button")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTarget && currentTarget?.parentElement) {
|
||||||
|
currentTarget.parentElement?.querySelector("input")?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
:data-align="props.align"
|
||||||
|
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
|
||||||
|
@click="handleInputGroupAddonClick"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
47
ui/src/components/ui/input-group/index.js
Normal file
47
ui/src/components/ui/input-group/index.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export { default as InputGroup } from "./InputGroup.vue";
|
||||||
|
export { default as InputGroupAddon } from "./InputGroupAddon.vue";
|
||||||
|
export { default as InputGroupButton } from "./InputGroupButton.vue";
|
||||||
|
export { default as InputGroupInput } from "./InputGroupInput.vue";
|
||||||
|
export { default as InputGroupText } from "./InputGroupText.vue";
|
||||||
|
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
|
||||||
|
|
||||||
|
export const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
26
ui/src/components/ui/select/Select.vue
Normal file
26
ui/src/components/ui/select/Select.vue
Normal 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>
|
||||||
81
ui/src/components/ui/select/SelectContent.vue
Normal file
81
ui/src/components/ui/select/SelectContent.vue
Normal 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>
|
||||||
14
ui/src/components/ui/select/SelectGroup.vue
Normal file
14
ui/src/components/ui/select/SelectGroup.vue
Normal 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>
|
||||||
49
ui/src/components/ui/select/SelectItem.vue
Normal file
49
ui/src/components/ui/select/SelectItem.vue
Normal 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>
|
||||||
14
ui/src/components/ui/select/SelectItemText.vue
Normal file
14
ui/src/components/ui/select/SelectItemText.vue
Normal 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>
|
||||||
20
ui/src/components/ui/select/SelectLabel.vue
Normal file
20
ui/src/components/ui/select/SelectLabel.vue
Normal 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>
|
||||||
30
ui/src/components/ui/select/SelectScrollDownButton.vue
Normal file
30
ui/src/components/ui/select/SelectScrollDownButton.vue
Normal 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>
|
||||||
30
ui/src/components/ui/select/SelectScrollUpButton.vue
Normal file
30
ui/src/components/ui/select/SelectScrollUpButton.vue
Normal 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>
|
||||||
21
ui/src/components/ui/select/SelectSeparator.vue
Normal file
21
ui/src/components/ui/select/SelectSeparator.vue
Normal 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>
|
||||||
37
ui/src/components/ui/select/SelectTrigger.vue
Normal file
37
ui/src/components/ui/select/SelectTrigger.vue
Normal 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>
|
||||||
15
ui/src/components/ui/select/SelectValue.vue
Normal file
15
ui/src/components/ui/select/SelectValue.vue
Normal 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>
|
||||||
11
ui/src/components/ui/select/index.js
Normal file
11
ui/src/components/ui/select/index.js
Normal 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";
|
||||||
314
ui/src/pages/TrainingReport.vue
Normal file
314
ui/src/pages/TrainingReport.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getTrainingReport, getTrainingReports } from '@/api/trainingReport';
|
||||||
|
import { CourseAttendee, CourseEventDetails, CourseEventSummary } from '@shared/types/course';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
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 };
|
||||||
|
|
||||||
|
const trainingReports = ref<CourseEventSummary[] | null>(null);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const sidePanel = computed<sidePanelState>(() => {
|
||||||
|
if (route.path.endsWith('/new')) return sidePanelState.create;
|
||||||
|
if (route.params.id) return sidePanelState.view;
|
||||||
|
return sidePanelState.closed;
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.params.id, async (newID) => {
|
||||||
|
if (!newID) {
|
||||||
|
focusedTrainingReport.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewTrainingReport(Number(route.params.id));
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusedTrainingReport = ref<CourseEventDetails | null>(null);
|
||||||
|
const focusedTrainingTrainees = computed<CourseAttendee[] | null>(() => {
|
||||||
|
if (focusedTrainingReport.value == null) return null;
|
||||||
|
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name == 'Trainee');
|
||||||
|
})
|
||||||
|
const focusedNoShows = computed<CourseAttendee[] | null>(() => {
|
||||||
|
if (focusedTrainingReport.value == null) return null;
|
||||||
|
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name == 'No-Show');
|
||||||
|
})
|
||||||
|
const focusedTrainingTrainers = computed<CourseAttendee[] | null>(() => {
|
||||||
|
if (focusedTrainingReport.value == null) return null;
|
||||||
|
return focusedTrainingReport.value.attendees.filter((attendee) => attendee.role.name != 'Trainee' && attendee.role.name != 'No-Show');
|
||||||
|
})
|
||||||
|
async function viewTrainingReport(id: number) {
|
||||||
|
focusedTrainingReport.value = await getTrainingReport(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTrainingReport() {
|
||||||
|
router.push(`/trainingReport`)
|
||||||
|
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(sortMode.value, searchString.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadTrainingReports();
|
||||||
|
if (route.params.id)
|
||||||
|
viewTrainingReport(Number(route.params.id))
|
||||||
|
loaded.value = true;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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 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="">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-[100px]">
|
||||||
|
Training
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead class="text-right">
|
||||||
|
Posted By
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody v-if="loaded">
|
||||||
|
<TableRow class="cursor-pointer" v-for="report in trainingReports" :key="report.event_id"
|
||||||
|
@click="router.push(`/trainingReport/${report.event_id}`)">
|
||||||
|
<TableCell class="font-medium">{{ report.course_name.length > 30 ? report.course_shortname :
|
||||||
|
report.course_name }}</TableCell>
|
||||||
|
<TableCell>{{ report.date }}</TableCell>
|
||||||
|
<TableCell class="text-right">{{ report.created_by_name === null ? "Unknown User" :
|
||||||
|
report.created_by_name
|
||||||
|
}}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- view training report section -->
|
||||||
|
<div v-if="focusedTrainingReport != null && sidePanel == sidePanelState.view" class="pl-9 my-3 border-l w-3/5">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">Training Report Details</p>
|
||||||
|
<button @click="closeTrainingReport" class="cursor-pointer">
|
||||||
|
<X></X>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<div class="flex flex-col gap-8 ">
|
||||||
|
<!-- Trainers -->
|
||||||
|
<div>
|
||||||
|
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainers</label>
|
||||||
|
<div class="grid grid-cols-4 py-2 text-sm font-medium text-muted-foreground border-b">
|
||||||
|
<span>Name</span>
|
||||||
|
<span class="">Role</span>
|
||||||
|
<span class="text-right col-span-2">Remarks</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="person in focusedTrainingTrainers"
|
||||||
|
class="grid grid-cols-4 py-2 items-center border-b last:border-none">
|
||||||
|
<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 == "" ?
|
||||||
|
'--'
|
||||||
|
: person.remarks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- trainees -->
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Trainees</label>
|
||||||
|
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b">
|
||||||
|
<span>Name</span>
|
||||||
|
<span class="">Bookwork</span>
|
||||||
|
<span class="">Qual</span>
|
||||||
|
<span class="text-right col-span-2">Remarks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="person in focusedTrainingTrainees"
|
||||||
|
class="grid grid-cols-5 py-2 items-center border-b last:border-none">
|
||||||
|
<p>{{ person.attendee_name }}</p>
|
||||||
|
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
|
||||||
|
:model-value="person.passed_bookwork" class="pointer-events-none ml-5">
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox :disabled="!focusedTrainingReport.course.hasQual"
|
||||||
|
: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 == "" ?
|
||||||
|
'--'
|
||||||
|
: person.remarks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- No Shows -->
|
||||||
|
<div v-if="focusedNoShows.length != 0">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label class="scroll-m-20 text-xl font-semibold tracking-tight">No Shows</label>
|
||||||
|
<div class="grid grid-cols-5 py-2 text-sm font-medium text-muted-foreground border-b">
|
||||||
|
<span>Name</span>
|
||||||
|
<!-- <span class="">Role</span>
|
||||||
|
<span class="">Role</span> -->
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<span class="text-right col-span-2">Remarks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="person in focusedNoShows"
|
||||||
|
class="grid grid-cols-5 py-2 items-center border-b last:border-none">
|
||||||
|
<p>{{ person.attendee_name }}</p>
|
||||||
|
<!-- <Checkbox :default-value="person.passed_bookwork ? true : false" class="pointer-events-none">
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox :default-value="person.passed_qual ? true : false" class="pointer-events-none">
|
||||||
|
</Checkbox> -->
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<p class="col-span-2 text-right px-2"
|
||||||
|
:class="person.remarks == '' ? 'text-muted-foreground' : ''">
|
||||||
|
{{ person.remarks == "" ?
|
||||||
|
'--'
|
||||||
|
: person.remarks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="scroll-m-20 text-xl font-semibold tracking-tight">Remarks</label>
|
||||||
|
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2"
|
||||||
|
:class="focusedTrainingReport.remarks == '' ? 'text-muted-foreground' : ''"> {{
|
||||||
|
focusedTrainingReport.remarks == "" ? 'None' : focusedTrainingReport.remarks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sidePanel == sidePanelState.create" class="pl-7 border-l w-3/5 max-w-5xl">
|
||||||
|
<div class="flex justify-between my-3">
|
||||||
|
<div class="flex pl-2 gap-5">
|
||||||
|
<p class="scroll-m-20 text-2xl font-semibold tracking-tight">New Training Report</p>
|
||||||
|
</div>
|
||||||
|
<button @click="closeTrainingReport" class="cursor-pointer">
|
||||||
|
<X></X>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Firefox */
|
||||||
|
.scrollbar-themed {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #555 #1f1f1f;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chrome, Edge, Safari */
|
||||||
|
.scrollbar-themed::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
/* slightly wider to allow padding look */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-themed::-webkit-scrollbar-track {
|
||||||
|
background: #1f1f1f;
|
||||||
|
margin-left: 6px;
|
||||||
|
/* ❗ adds space between content + scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-themed::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,6 +16,9 @@ const router = createRouter({
|
|||||||
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||||
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||||
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||||
|
{ 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', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
|
||||||
|
|
||||||
// ADMIN / STAFF ROUTES
|
// ADMIN / STAFF ROUTES
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@shared": ["../shared/*"]
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
@@ -15,7 +15,8 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user