diff --git a/deno.json b/deno.json index 45c5870..c7f729b 100644 --- a/deno.json +++ b/deno.json @@ -35,7 +35,8 @@ "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "$std/": "https://deno.land/std@0.216.0/", - "$root/": "./" + "$root/": "./", + "$apps/": "./routes/(apps)/" }, "compilerOptions": { "jsx": "react-jsx", diff --git a/fresh.gen.ts b/fresh.gen.ts index 3061dcd..eeb5302 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -14,11 +14,11 @@ import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/part import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; -import * as $_apps_students_api_types_d from "./routes/(apps)/students/api/types.d.ts"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; +import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; import * as $_404 from "./routes/_404.tsx"; import * as $_app from "./routes/_app.tsx"; import * as $_middleware from "./routes/_middleware.ts"; @@ -56,7 +56,6 @@ const manifest = { "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/students/api/students.ts": $_apps_students_api_students, - "./routes/(apps)/students/api/types.d.ts": $_apps_students_api_types_d, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -64,6 +63,7 @@ const manifest = { $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": $_apps_students_partials_index, + "./routes/(apps)/students/types.d.ts": $_apps_students_types_d, "./routes/_404.tsx": $_404, "./routes/_app.tsx": $_app, "./routes/_middleware.ts": $_middleware, diff --git a/routes/(apps)/students/(_components)/Promotion.tsx b/routes/(apps)/students/(_components)/Promotion.tsx new file mode 100644 index 0000000..985b637 --- /dev/null +++ b/routes/(apps)/students/(_components)/Promotion.tsx @@ -0,0 +1,30 @@ +import Student from "$root/routes/(apps)/students/(_components)/Student.tsx"; + +type PromotionProps = { students: Student[]; promo: Promotion }; + +export default function Promotion(props: PromotionProps) { + if (!props.promo) { + return

Unable to find user in database.

; + } + + return ( +
+

Promotion {props.promo.endyear}

+ + + + + + + + + + + {props.students + .filter((student) => student.promotionId === props.promo.id) + .map((student) => )} + +
IDFirst NameLast NameEmail
+
+ ); +} diff --git a/routes/(apps)/students/(_components)/SelfPortrait.tsx b/routes/(apps)/students/(_components)/SelfPortrait.tsx new file mode 100644 index 0000000..a505298 --- /dev/null +++ b/routes/(apps)/students/(_components)/SelfPortrait.tsx @@ -0,0 +1,31 @@ +import { CasContent } from "$root/defaults/interfaces.ts"; + +type SelfPortraitProps = { self: CasContent }; + +const regex = + /^(?\d{4})(?\d{2})(?\d{2})(?\d{2})(?\d{2})(?\d{2})Z$/; + +export default function SelfPortrait(props: SelfPortraitProps) { + const { year, month, date, hours, minutes, seconds } = props.self + .amuDateValidation.match(regex)!.groups!; + + const validationIsoDate = + `${year}-${month}-${date}T${hours}:${minutes}:${seconds}Z`; + + const validationDate = new Date(validationIsoDate); + + return ( +
+
Identity
+
{props.self.supannCivilite} {props.self.displayName}
+
Student number
+
{props.self.uid}
+
amU mail
+
{props.self.mail}
+
First amU registration
+
{validationDate.toLocaleString()}
+
amU class code
+
{props.self.supannEtuEtape}
+
+ ); +} diff --git a/routes/(apps)/students/(_components)/Student.tsx b/routes/(apps)/students/(_components)/Student.tsx new file mode 100644 index 0000000..7da0e84 --- /dev/null +++ b/routes/(apps)/students/(_components)/Student.tsx @@ -0,0 +1,13 @@ +type StudentProps = { student: Student; promo?: number }; + +export default function Student(props: StudentProps) { + return ( + + {props.student.userId} + {props.student.firstName} + {props.student.lastName} + {props.student.mail} + {props.promo && {props.promo}} + + ); +} diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 835754f..f67036b 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,75 +1,45 @@ import { useEffect, useState } from "preact/hooks"; +import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx"; -interface Promotion { - id: number; - name: string; -} +type SingleUserResponse = { promo: Promotion; student: Student }; +type ManyUsersResponse = { promos: Promotion[]; students: Student[] }; -interface Student { - userId: string; - firstName: string; - lastName: string; - mail: string; - promotionId: number; -} +type APIResponse = SingleUserResponse | ManyUsersResponse; export default function ConsultStudents() { - const [data, setData] = useState< - { promotions: Promotion[]; students: Student[] } | null - >(null); + const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { - try { - const response = await fetch("/students/api/students"); - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("Fetched data:", result); - setData(result); - } catch (err) { - console.error("Error fetching data:", err); + const response = await fetch("/students/api/students"); + if (!response.ok) { setError("Failed to load data. Please try again later."); } + + const result: APIResponse = await response.json(); + setData(result); }; fetchData(); }, []); return ( -
-

Consult Students

+ <> {error &&

{error}

} - {data?.promotions.map((promo) => ( -
-

Promotion: {promo.name}

- - - - - - - - - - - {data.students - .filter((student) => student.promotionId === promo.id) - .map((student) => ( - - - - - - - ))} - -
IDFirst NameLast NameEmail
{student.userId}{student.firstName}{student.lastName}{student.mail}
-
- ))} -
+ {data && ((Object.hasOwn(data, "student")) + ? ( + + ) + : (data as ManyUsersResponse).promos.map((promo) => ( + + )))} + ); } diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 6ff1d5a..1ae11d7 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -1,75 +1,111 @@ // @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; -import { useSignal } from "@preact/signals"; +import { Signal, useSignal } from "@preact/signals"; -export default function UploadStudents() { - const statusMessage = useSignal(""); - const fileData = useSignal(null); - - const handleFileChange = (event: Event) => { +/** + * Create a new handler for file change that displays + * messages in statusMessage and gets file data in fileData. + * @param statusMessage The status message signal. + * @param fileData The file data signal. + * @returns The file change handler. + */ +function getFileChangeHandler( + statusMessage: Signal, + fileData: Signal, +): (event: Event) => void { + /** + * Handle file change. + * @param event The file change event. + */ + return (event: Event) => { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { fileData.value = input.files[0]; - statusMessage.value = "File selected: " + input.files[0].name; + statusMessage.value = `File selected: ${input.files[0].name}`; } else { fileData.value = null; statusMessage.value = "No file selected"; } }; +} - const confirmUpload = () => { +/** + * Create a new handler that sends data file to server. + * @param statusMessage The status message signal. + * @param fileData The file data signal. + * @returns The file confirmation handler. + */ +function getUploadConfirmationFunction( + statusMessage: Signal, + fileData: Signal, +): () => void { + /** + * Add students to database. + * @returns Confirm upload of students. + */ + return () => { if (!fileData.value) { statusMessage.value = "Please select a file before confirming upload."; return; } - try { - const reader = new FileReader(); + const reader = new FileReader(); - reader.onload = async (e) => { - const arrayBuffer = e.target?.result as ArrayBuffer; - const workbook = XLSX.read(arrayBuffer, { type: "array" }); + /** + * Send all data to the server. + * @param event The finished progress event. + */ + reader.onload = async (event: ProgressEvent) => { + const arrayBuffer = event.target!.result as ArrayBuffer; + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + let allOK = true; - for (const sheetName of workbook.SheetNames) { - const sheet = workbook.Sheets[sheetName]; - const data = XLSX.utils.sheet_to_json(sheet, { - header: ["Identifiant", "Nom", "Prénom", "Mail"], - range: 1, // Ignorer les en-têtes - }); + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + const data = XLSX.utils.sheet_to_json(sheet, { + header: ["userId", "lastName", "firstName", "mail"], + range: 1, + }); - console.log(`Data from sheet ${sheetName}:`, data); + const response = await fetch("/students/api/students", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ promoName: sheetName, data }), + }); - const response = await fetch("/students/api/students", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ promoName: sheetName, data }), - }); - - if (!response.ok) { - throw new Error(`Failed to insert data for promotion ${sheetName}`); - } + if (!response.ok) { + allOK = false; } + } - statusMessage.value = "Data uploaded and inserted successfully!"; - }; + statusMessage.value = allOK + ? "Failed to insert all data." + : "Data uploaded and inserted successfully!"; + }; - reader.onerror = () => { - statusMessage.value = "Error reading the file."; - }; + /** + * Display error message if any. + */ + reader.onerror = () => { + statusMessage.value = "Error reading the file."; + }; - reader.readAsArrayBuffer(fileData.value); - } catch (error) { - console.error("Error uploading file:", error); - statusMessage.value = "An unexpected error occurred during upload."; - } + reader.readAsArrayBuffer(fileData.value); }; +} + +export default function UploadStudents() { + const statusMessage = useSignal(""); + const fileData = useSignal(null); + + const handleFileChange = getFileChangeHandler(statusMessage, fileData); + const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData); return ( -
-

Upload Students

+ <>

{statusMessage.value}

-
+ ); } diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index eb1dd2b..157299f 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -32,31 +32,50 @@ function getItself( /** * Gets itself from the database. - * @param database The database connection + * @param database The database connexion * @param userId The user ID. * @returns Itself from the database. */ function getAll( database: Database, - userId: string, -): { student: Student | null; promo: Promotion | null } { - const studentQuery = "select * from students where userId = ?"; - const student: Student | undefined = database.prepare(studentQuery).get( - userId, - ); +): { students: Student[]; promos: Promotion[] } { + const studentsQuery = ` + select userId, firstName, lastName, mail, promotionId + from students inner join promotions + on students.promotionId = promotions.id + where promotions.current < 6`; + const students: Student[] = database.prepare(studentsQuery).all(); - if (!student) { - return { student: null, promo: null }; - } + const promosQuery = "select * from promotions where promotions.current < 6"; + const promos: Promotion[] | undefined = database.prepare(promosQuery).all(); - const promoQuery = "select * from promotions where id = ?"; - const promo: Promotion | undefined = database.prepare(promoQuery).get( - student.promotionId, - ); - - return { student, promo: promo ?? null }; + return { students, promos }; } +/** + * Add users to the database. + * @param database The database connexion + * @param students The students to add + * @param promoId The promotion id. + */ +function addStudents(database: Database, students: Student[], promoId: string) { + const query = ` + INSERT INTO students + (userId, firstName, lastName, mail, promotionId) + VALUES (?, ?, ?, ?, ?)`; + + const statement = database.prepare(query); + + for (const student of students) { + statement.run( + student.userId, + student.firstName, + student.lastName, + student.mail, + promoId, + ); + } +} export const handler: Handlers = { /** @@ -75,9 +94,7 @@ export const handler: Handlers = { if (context.state.session.eduPersonPrimaryAffiliation == "student") { return new Response( - JSON.stringify({ - student: getItself(database, context.state.session.uid), - }), + JSON.stringify(getItself(database, context.state.session.uid)), { headers: { "content-type": "application/json", @@ -86,70 +103,49 @@ export const handler: Handlers = { ); } - const promotions = database.prepare("select id, name from promotions") - .all(); - - const students = database.prepare( - "select userId, firstName, lastName, mail, promotionId from students", - ).all(); - return new Response( - JSON.stringify({ promotions, students }), + JSON.stringify(getAll(database)), { - status: 200, - headers: { "Content-Type": "application/json" }, + headers: { + "content-type": "application/json", + }, }, ); }, + /** + * Add students in the database. + * @param request The HTTP request. + * @param _context The Fresh context. + * @returns HTTP 201 on successful insert. + */ + async POST( + request: Request, + _context: FreshContext, + ): Promise { + const { students, promo }: { students: Student[]; promo: string } = + await request.json(); - async POST(request) { - console.log("API /students/api/insert_students called"); - - try { - const body = await request.json(); - const { data, promoName } = body; - - console.log("Received data:", { promoName, data }); - - if (!promoName || !Array.isArray(data)) { - throw new Error("Invalid request body"); - } - - using connection = connect("students"); - - connection.database.prepare( - "INSERT OR IGNORE INTO promotions (name) VALUES (?)", - ).run(promoName); - - const promoIdRow: { id: number } = connection.database - .prepare("SELECT id FROM promotions WHERE name = ?") - .get(promoName)!; - const promoId = promoIdRow.id; - - console.log(`Promotion ID for "${promoName}":`, promoId); - - const insertQuery = connection.database.prepare( - `INSERT INTO students - (userId, firstName, lastName, mail, promotionId) - VALUES (?, ?, ?, ?, ?)`, - ); - - for (const student of data) { - console.log("Inserting student:", student); - insertQuery.run( - student.Identifiant, - student.Nom, - student["Prénom"], - student.Mail, - promoId, - ); - } - - console.log("All data inserted successfully"); - return new Response("Data inserted successfully", { status: 201 }); - } catch (error) { - console.error("Error inserting data:", error); - return new Response("Failed to insert data", { status: 500 }); + if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(students)) { + return new Response(null, { status: 400 }); } + + using connection = connect("students"); + const database = connection.database; + + const { endyear, current } = promo.match( + /^(?\d{4})-(?\d)A$/, + )?.groups!; + + database.prepare( + "insert or ignore into promotions (endyear, current) values (?, ?)", + ).run(endyear, current); + + const { id: promoId }: { id: string } = database + .prepare("select id from promotions where endyear = ? and current = ?") + .get(endyear, current)!; + + addStudents(database, students, promoId); + + return new Response(null, { status: 201 }); }, }; diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index c46c2a5..b685c5c 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -1,4 +1,4 @@ -import ConsultStudents from "$root/routes/(apps)/students/(_islands)/ConsultStudents.tsx"; +import ConsultStudents from "../../(_islands)/ConsultStudents.tsx"; import { getPartialsConfig, makePartials, @@ -10,7 +10,7 @@ import { State } from "$root/defaults/interfaces.ts"; async function Students(_request: Request, _context: FreshContext) { return ( <> -

Manage Promotions

+

Consult students

); diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index d64608a..2f36f6d 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -1,4 +1,4 @@ -import UploadStudents from "$root/routes/(apps)/students/(_islands)/UploadStudents.tsx"; +import UploadStudents from "../../(_islands)/UploadStudents.tsx"; import { getPartialsConfig, makePartials, @@ -10,7 +10,7 @@ import { State } from "$root/defaults/interfaces.ts"; async function Students(_request: Request, _context: FreshContext) { return ( <> -

Manage Promotions

+

Upload Students

); diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index a54dff7..78931b5 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -4,10 +4,17 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; +import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx"; // deno-lint-ignore require-await export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; + return ( + <> +

Welcome {context.state.session?.givenName}!

+

Your amU identity

+ + + ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/api/types.d.ts b/routes/(apps)/students/types.d.ts similarity index 100% rename from routes/(apps)/students/api/types.d.ts rename to routes/(apps)/students/types.d.ts diff --git a/routes/_app.tsx b/routes/_app.tsx index 8b253b8..60d03a9 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -27,6 +27,7 @@ export default async function App( +
diff --git a/static/styles/app.css b/static/styles/app.css index 8ddee03..8395076 100644 --- a/static/styles/app.css +++ b/static/styles/app.css @@ -3,7 +3,7 @@ padding: 1em 0; display: grid; grid-template-columns: auto 1fr; - gap: 1em; + gap: 4em; } #app > #app-body { @@ -16,7 +16,7 @@ } #app > nav > a { - padding: 0.25em 0.5em; + padding: 0.5em 4em 0.5em 1em; color: light-dark(var(--light-foreground), var(--dark-foreground)); } @@ -57,5 +57,10 @@ #app { grid-template-rows: auto 1fr; grid-template-columns: none; + gap: 1em; + } + + #app > nav > a { + padding: 0.5em 1em; } } diff --git a/static/styles/students.css b/static/styles/students.css new file mode 100644 index 0000000..d7398bd --- /dev/null +++ b/static/styles/students.css @@ -0,0 +1,15 @@ +#self-portrait { + display: grid; + gap: 1em; + grid-template-columns: auto 1fr; +} + +#self-portrait > div:nth-child(2n+1) { + font-weight: var(--font-weight-bold); +} + +@media screen and (max-width: 1024px) { + #self-portrait { + grid-template-columns: 1fr; + } +}