diff --git a/src/lib/components/AdminLayout.svelte b/src/lib/components/AdminLayout.svelte index 71350f6fd8a22129fe7e85beba7c66d37e39542f..795355d3f98042bcdf7cb3ab48835ce06f727e9d 100644 --- a/src/lib/components/AdminLayout.svelte +++ b/src/lib/components/AdminLayout.svelte @@ -33,6 +33,8 @@ <DropdownItem href="/admin/tutor">Übersicht</DropdownItem> <DropdownItem href="/admin/tutor/training">Schulungen</DropdownItem> <DropdownItem href="/admin/tutor/mail">Mail senden</DropdownItem> + <DropdownItem href="/admin/tutor/files">Dateien</DropdownItem> + <DropdownItem href="/admin/tutor/studyprogram">Studiengänge</DropdownItem> <DropdownDivider /> <DropdownItem href="/admin/tutor/former">Alttutoren</DropdownItem> </Dropdown> @@ -44,6 +46,7 @@ <DropdownItem href="/admin/rallye/station">Stationen</DropdownItem> <DropdownItem href="/admin/rallye/points">Punkte</DropdownItem> </Dropdown> + <NavLi href="/admin/mr-x">Mr. X</NavLi> <NavLi href="/admin/rabatte">Rabatte</NavLi> <NavLi href="/admin/flyer">Flyer</NavLi> <NavLi href="/admin/uploads">Uploads</NavLi> diff --git a/src/lib/components/MailInput.svelte b/src/lib/components/MailInput.svelte index 974e1097d925c8811bdfd044b2e76e25bda839f0..d77899aad1de21b5bdb01ea77c9c63171a48977d 100644 --- a/src/lib/components/MailInput.svelte +++ b/src/lib/components/MailInput.svelte @@ -2,7 +2,7 @@ import Degree from "$lib/degrees"; import Gender from "$lib/genders"; import { locale, locales } from "$lib/i18n/i18n"; - import { variables, conditions, compileTemplate, type PartialConfig, type TemplateType } from "$lib/mail"; + import { compileTemplate, type PartialConfig, type TemplateType } from "$lib/mail"; import type { StudyProgram } from "$lib/server/database/entities/StudyProgram.entity"; import type { Tutor } from "$lib/server/database/entities/Tutor.entity"; import type { TutorTraining } from "$lib/server/database/entities/TutorTraining.entity"; @@ -48,6 +48,8 @@ birthday: "1970-01-01", coTutorWish: "Maxine Musterfrau", degree: "Bachelor", + wouldLeadBachelor: true, + wouldLeadMaster: false, gender: "m", mentor: false, notes: "Montag noch nicht in Aachen", diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e02aa76cf44f1654dc08656dd11493ed930f905 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,13 @@ +export function toCSV<T extends Record<string, unknown>>(data: T[], fields: (keyof T)[]): string { + return fields.map(f=>escape(f as string)).join(",") + "\n" + data.map(o=>{ + return fields.map(f=>{ + const val = o[f]; + if(val === undefined || val === null) return ""; + else return escape(String(o[f])); + }).join(",") + "\n"; + }).join(""); +} + +function escape(s: string): string { + return `"${s.replaceAll('"', '""')}"`; +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 27344630797745d975fccef3208951ebb286f85d..42a9abbc099216a9b0b03962156ef6f6bdd6d3b3 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -29,10 +29,15 @@ export default { "/admin/tutor": "Tutor:innen verwalten", "/admin/tutor/[id=number]": "Tutor:in bearbeiten: {tutor.firstname} {tutor.lastname}", "/admin/tutor/mail": "E-Mail senden", + "/admin/tutor/studyprogram": "Tutor-Studiengänge verwalten", "/admin/tutor/training": "Tutschulungen verwalten", "/admin/tutor/training/[id=number]": "Tutschulung bearbeiten: {training.date}", "/admin/tutor/training/new": "Neue Tutschulung erstellen", + "/admin/uploads": "Hochgeladene Bilder", "/admin/user": "Berechtigungen verwalten", + "/intern": "Login", + "/intern/rally": "", // TODO + "/intern/tutor": "", // TODO }, Navbar: { Branding: "Fachschaft I/1", @@ -41,6 +46,7 @@ export default { "/rabatte": "Rabatte", "/eswe": "Erstsemesterwochenende", "/flyer": "Flyer", + "/mr-x": "Mr. X", "/tutor": "Tutor", "/upload": "Momente teilen", }, @@ -126,16 +132,24 @@ export default { Email: "RWTH-Mail", Phone: "Telefonnummer", PhoneHelper: "Für kurzfristige Kommunikation v.a. während der Erstiwoche wollen wir dich eventuell anrufen", - Address: "Adresse", + FullAddress: "Adresse", + Address: "Straße und Hausnummer", + Zip: "Postleitzahl", + City: "Ort", Gender: "Geschlecht", ShirtSize: "T-Shirt-Größe", + ShirtSizeHelper: "Das T-Shirt muss während der Erstirallye als oberste Lage getragen werden, also über deiner Jacke. Im Notfall gib lieber eine Nummer größer an, damit es auch passt.", DietaryRestriction: "Essgewohnheiten und Unverträglichkeiten", DietaryRestrictionHelper: "Bei Veranstaltungen wie bspw. der Tutschulung gibt es Verpflegung. Unverträglichkeiten und Einschränkungen wie Veganismus zu kennen hilft uns, allen etwas anbieten zu können.", StudyProgram: "Studiengang", StudyProgramWaitlist: "Für den Studiengang {studyProgram} werden voraussichtlich keine weiteren Tutoren benötigt. Wenn du dich trotzdem anmeldest, wirst du auf die Warteliste gesetzt und wir melden uns bei dir, falls wir dich doch noch brauchen.", Degree: "Abschluss", DegreeHelper: "Der Abschluss, den du gerade anstrebst", + WouldLeadBachelor: "Ich würde ein Bachelor-Tutorium leiten", + WouldLeadMaster: "Ich würde ein Master-Tutorium leiten", Training: "Schulung", + TrainingOption: "{date|dateWithWeekday} ({language})", + TrainingOptionFull: "{date|dateWithWeekday} ({language}) - voll", AlreadyTrained: "Bereits geschult", CoTutorWish: "Wunsch-Co-Tutor (optional)", CoTutorWishHelper: "Ihr müsst beide das gleiche Fach studieren. In der Informatik muss ein:e Mentor:in dabei sein.", @@ -249,4 +263,17 @@ export default { ], Footer: "Sofern nicht anders angegeben, ist die männliche Form in diesem Text nicht geschlechterspezifisch gemeint, sondern wurde aus Gründen der Lesbarkeit gewählt.", }, + Internal: { + Settigs: { + Headline: "Einstellungen", + ProfileHeadline: "Profil", + PasswordHeadline: "Passwort", + Save: "Speichern", + OldPassword: "Altes Passwort", + NewPassword: "Neues Passwort", + NewPasswordRepeat: "Neues Passwort wiederholen", + PasswordMismatch: "Die Passwörter stimmen nicht überein", + PasswordTooShort: "Das Passwort muss mindestens 8 Zeichen lang sein", + }, + }, } diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 5781661d73bc1664ccf9b8d2b44dc770d0c2b9c5..266c6a13d191092bd47cc580eb92a0d33ce79f85 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -31,10 +31,15 @@ export default { "/admin/tutor": "Manage Tutors", "/admin/tutor/[id=number]": "Edit Tutor: {tutor.firstname} {tutor.lastname}", "/admin/tutor/mail": "Send Email", + "/admin/tutor/studyprogram": "Manage Tutor Study Programs", "/admin/tutor/training": "Manage Tutor Trainings", "/admin/tutor/training/[id=number]": "Edit tutor training: {training.date}", "/admin/tutor/training/new": "Create new tutor training", + "/admin/uploads": "Uploaded photos", "/admin/user": "Manage Permissions", + "/intern": "Login", + "/intern/rally": "", // TODO + "/intern/tutor": "", // TODO }, Navbar: { Branding: "Student Council I/1", @@ -43,6 +48,7 @@ export default { "/rabatte": "Discounts", "/eswe": "Freshers' Weekend", "/flyer": "Flyer", + "/mr-x": "Mr. X", "/tutor": "Tutor", "/upload": "Share Moments", }, @@ -129,16 +135,24 @@ export default { Email: "RWTH-Mail", Phone: "Phone Number", PhoneHelper: "For short-term communication, especially during the freshers' week, we may want to call you", - Address: "Address", + FullAddress: "Address", + Address: "Street and House Number", + Zip: "Zip Code", + City: "City", Gender: "Gender", ShirtSize: "T-Shirt Size", + ShirtSizeHelper: "The T-shirt must be worn as the top layer during the freshers' rally, i.e. over your jacket. In case of doubt, please give a larger size so that it fits.", DietaryRestriction: "Dietary Restrictions", DietaryRestrictionHelper: "We offer food for events like the tutor trainings. Knowing about your dietary restrictions helps us to plan the food accordingly.", StudyProgram: "Study Program", StudyProgramWaitlist: "We probably won't need any more tutors for {studyProgram|lowercase} this year. If you sign up anyway, we will put you on the waiting list and contact you if we need more tutors.", Degree: "Degree", DegreeHelper: "The degree you are currently pursuing", + WouldLeadBachelor: "I would lead a bachelor tutorial", + WouldLeadMaster: "I would lead a master tutorial", Training: "Training", + TrainingOption: "{date|dateWithWeekday} ({language})", + TrainingOptionFull: "{date|dateWithWeekday} ({language}) - full", AlreadyTrained: "Already trained", CoTutorWish: "Co-Tutor Wish (optional)", CoTutorWishHelper: "You both have to study the same subject. In computer science, one of you has to be a mentor.", @@ -252,4 +266,17 @@ export default { ], Footer: "Sofern nicht anders angegeben, ist die männliche Form in diesem Text nicht geschlechterspezifisch gemeint, sondern wurde aus Gründen der Lesbarkeit gewählt.", }, + Internal: { + Settigs: { + Headline: "Settings", + ProfileHeadline: "Profile", + PasswordHeadline: "Password", + Save: "Save", + OldPassword: "Old password", + NewPassword: "New password", + NewPasswordRepeat: "Repeat new password", + PasswordMismatch: "The passwords do not match", + PasswordTooShort: "The password must be at least 8 characters long", + }, + }, } satisfies Translation<LocalizedString>; diff --git a/src/lib/i18n/i18n.ts b/src/lib/i18n/i18n.ts index e95900f923a8f69704bc4948fb620c0d7c7bbd6a..46fc16a489c4ab4ff0e1ef2ef1e8ccc6fc79b5c9 100644 --- a/src/lib/i18n/i18n.ts +++ b/src/lib/i18n/i18n.ts @@ -141,6 +141,7 @@ function generateLObject(obj: typeof de, locale: Locale): Translation { const formatterBuilders: Record<string, (lang: Locale)=>(arg: any)=>unknown> = { dateLong: (lang: Locale)=>new Intl.DateTimeFormat(lang, {year: "numeric", month: "long", day: "2-digit"}).format, dateRangeLong: (lang: Locale)=>(value: [number|Date, number|Date])=>new Intl.DateTimeFormat(lang, {year: "numeric", month: "long", day: "2-digit"}).formatRange(value[0], value[1]), + dateWithWeekday: (lang: Locale)=>new Intl.DateTimeFormat(lang, {weekday: "short", year: "numeric", month: "long", day: "2-digit"}).format, sanitize: ()=>(input: string)=>input.replace(/[^a-zA-Z0-9_ ()äöüÄÖÜß-]/g, "-"), lowercase: ()=>(input: string)=>input.toLowerCase(), } diff --git a/src/lib/perms.ts b/src/lib/perms.ts index 95f2fe8909640843e6af9bc3404e25c3503bd9e9..6bfda68d4327478707a11b572ada44492f4686be 100644 --- a/src/lib/perms.ts +++ b/src/lib/perms.ts @@ -12,6 +12,8 @@ export enum Permission { EDIT_TRAININGS = 1<<10, UPDATE_CONFIG = 1<<11, UPDATE_MAIL_TEMPLATES = 1<<12, + UPDATE_RALLY_STATIONS = 1<<13, + UPDATE_RALLY_POINTS = 1<<14, } export const PermissionDescription: {[permission in Permission]: string} = { [Permission.ADMIN]: "Berechtigungen aller Nutzer setzen", @@ -27,6 +29,8 @@ export const PermissionDescription: {[permission in Permission]: string} = { [Permission.EDIT_TRAININGS]: "Erstellen, Bearbeiten und Löschen von Schulungen", [Permission.UPDATE_CONFIG]: "Grundeinstellungen ändern", [Permission.UPDATE_MAIL_TEMPLATES]: "E-Mail-Vorlagen bearbeiten", + [Permission.UPDATE_RALLY_STATIONS]: "Rallye-Stationen erstellen/löschen/bearbeiten", + [Permission.UPDATE_RALLY_POINTS]: "Bewertungen für Rallye-Stationen bearbeiten und auswerten", }; export class PermissionSet { diff --git a/src/lib/server/database/entities/StudyProgram.entity.ts b/src/lib/server/database/entities/StudyProgram.entity.ts index ea0051fe52a356175d36ce6971a2561998dd78bc..e7d558e6882038ee53f3ad16db49e4706cb6ba28 100644 --- a/src/lib/server/database/entities/StudyProgram.entity.ts +++ b/src/lib/server/database/entities/StudyProgram.entity.ts @@ -4,8 +4,6 @@ import { sql } from "../db"; export class StudyProgram { id!: number; name!: Localized; - tutorsWanted!: number; - tutorialNames!: string[]; static parse(program: any){ if(!program) return undefined; @@ -29,8 +27,4 @@ export class StudyProgram { // TODO handle foreign key constraints await sql`DELETE FROM study_programs WHERE id=${id}`; } - static async getMissingTutors(): Promise<{[id: number]: number|null}>{ - const rows = await sql`SELECT study_programs.id, study_programs.tutors_wanted, COUNT(tutors.id) AS tutors FROM study_programs LEFT JOIN tutors ON study_programs.id=tutors.study_program GROUP BY study_programs.id`; - return rows.reduce((acc, row)=>({...acc, [row.id]: row.tutorsWanted ? row.tutorsWanted - row.tutors : null}), {}); - } } diff --git a/src/lib/server/database/entities/Tutor.entity.ts b/src/lib/server/database/entities/Tutor.entity.ts index 73b8ce3eedff1c12ae1a444686378715a67e6ad8..a65ec6498ae5bc87823671d60bf9f3dcbae55989 100644 --- a/src/lib/server/database/entities/Tutor.entity.ts +++ b/src/lib/server/database/entities/Tutor.entity.ts @@ -3,7 +3,7 @@ import type { Gender } from "$lib/genders"; import type { PartialExcept } from "$lib/utils"; import { sql } from "../db"; import { FormerTutor } from "./FormerTutor.entity"; -import { StudyProgram } from "./StudyProgram.entity"; +import { TutorStudyProgram } from "./TutorStudyProgram.entity"; import { Tutorial } from "./Tutorial.entity"; import { TutorTraining } from "./TutorTraining.entity"; @@ -20,12 +20,14 @@ export class Tutor { shirtSize!: string; degree!: keyof typeof Degree; dietaryRestriction?: string|null; - studyProgram!: StudyProgram; + studyProgram!: TutorStudyProgram; training?: TutorTraining|null; sentTrainingMail!: boolean; trained!: boolean; coTutorWish!: string; mentor!: boolean; + wouldLeadBachelor!: boolean; + wouldLeadMaster!: boolean; tutorial?: Tutorial|null; notes!: string; number?: string|null; @@ -36,7 +38,7 @@ export class Tutor { if(!tutor) return undefined; const t: Tutor = Object.assign(new Tutor(), tutor); if(tutor.training) t.training = TutorTraining.parse(tutor.training); - if(tutor.studyProgram) t.studyProgram = StudyProgram.parse(tutor.studyProgram)!; + if(tutor.studyProgram) t.studyProgram = TutorStudyProgram.parse(tutor.studyProgram)!; if(tutor.tutorial) t.tutorial = Tutorial.parse(tutor.tutorial); if(tutor.former) t.former = FormerTutor.parse(tutor.former); return t; @@ -44,17 +46,17 @@ export class Tutor { // TODO include tutorial in getById, getByEmail and getAll static async getById(id: number){ - const row = (await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN study_programs ON tutors.study_program=study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email WHERE tutors.id=${id}`)[0]; + const row = (await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(tutor_study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN tutor_study_programs ON tutors.study_program=tutor_study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email WHERE tutors.id=${id}`)[0]; if(!row) return undefined; return Tutor.parse(Object.assign(row.tutor, { training: row.training, studyProgram: row.studyProgram })); } static async getByEmail(email: string){ - const row = (await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN study_programs ON tutors.study_program=study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email WHERE tutors.email=${email}`)[0]; + const row = (await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(tutor_study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN tutor_study_programs ON tutors.study_program=tutor_study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email WHERE tutors.email=${email}`)[0]; if(!row) return undefined; return Tutor.parse(Object.assign(row.tutor, { training: row.training, studyProgram: row.studyProgram })); } static async getAll(){ - const rows = await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN study_programs ON tutors.study_program=study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email`; + const rows = await sql`SELECT row_to_json(tutors.*) AS tutor, row_to_json(tutor_trainings.*) AS training, row_to_json(tutor_study_programs.*) AS study_program, row_to_json(former_tutors.*) AS former FROM tutors LEFT JOIN tutor_trainings ON tutors.training=tutor_trainings.id LEFT JOIN tutor_study_programs ON tutors.study_program=tutor_study_programs.id LEFT JOIN former_tutors ON tutors.email=former_tutors.email`; return rows.map(row => Tutor.parse(Object.assign(row.tutor, { training: row.training, studyProgram: row.studyProgram }))!); } static async create(tutor: Omit<Tutor, "id"|"studyProgram"|"training"|"tutorial"|"former">&{studyProgram: number, training?: number|null}){ diff --git a/src/lib/server/database/entities/TutorStudyProgram.entity.ts b/src/lib/server/database/entities/TutorStudyProgram.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..353e5f59597f71fdfd366f3e172532f29dd95ff1 --- /dev/null +++ b/src/lib/server/database/entities/TutorStudyProgram.entity.ts @@ -0,0 +1,36 @@ +import type { Localized } from "$lib/utils"; +import { sql } from "../db"; + +export class TutorStudyProgram { + id!: number; + name!: Localized; + tutorsWanted!: number; + tutorialNames!: string[]; + + static parse(program: any){ + if(!program) return undefined; + const p: TutorStudyProgram = Object.assign(new TutorStudyProgram(), program); + return p; + } + + static async getById(id: number): Promise<TutorStudyProgram|undefined> { + return TutorStudyProgram.parse((await sql`SELECT * FROM tutor_study_programs WHERE id=${id}`)[0]); + } + static async getAll(){ + return (await sql`SELECT * FROM tutor_study_programs ORDER BY id ASC`).map(program=>TutorStudyProgram.parse(program)!); + } + static async update(updatedProgram: TutorStudyProgram){ + await sql`UPDATE tutor_study_programs SET ${sql(updatedProgram)} WHERE id=${updatedProgram.id}`; + } + static async create(program: Omit<TutorStudyProgram, "id">){ + await sql`INSERT INTO tutor_study_programs ${sql(program)}`; + } + static async delete(id: number){ + // TODO handle foreign key constraints + await sql`DELETE FROM tutor_study_programs WHERE id=${id}`; + } + static async getMissingTutors(): Promise<{[id: number]: number|null}>{ + const rows = await sql`SELECT tutor_study_programs.id, tutor_study_programs.tutors_wanted, COUNT(tutors.id) AS tutors FROM tutor_study_programs LEFT JOIN tutors ON tutor_study_programs.id=tutors.study_program GROUP BY tutor_study_programs.id`; + return rows.reduce((acc, row)=>({...acc, [row.id]: row.tutorsWanted ? row.tutorsWanted - row.tutors : null}), {}); + } +} diff --git a/src/lib/server/database/migrations/0_init.sql b/src/lib/server/database/migrations/0_init.sql index e6fbdacd6805ae35ff621d95398598006e0e9ceb..235288bae84ae09f1c72ea2b88b1456625432aea 100644 --- a/src/lib/server/database/migrations/0_init.sql +++ b/src/lib/server/database/migrations/0_init.sql @@ -1,4 +1,9 @@ CREATE TABLE IF NOT EXISTS study_programs ( + "id" SERIAL PRIMARY KEY, + "name" JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS tutor_study_programs ( "id" SERIAL PRIMARY KEY, "name" JSONB NOT NULL, "tutors_wanted" INT NOT NULL DEFAULT 0, @@ -41,12 +46,14 @@ CREATE TABLE IF NOT EXISTS tutors ( "gender" TEXT NOT NULL, "shirt_size" TEXT NOT NULL, "dietary_restriction" TEXT NOT NULL DEFAULT '', - "study_program" INT NOT NULL REFERENCES study_programs(id), + "study_program" INT NOT NULL REFERENCES tutor_study_programs(id), "training" INT DEFAULT NULL REFERENCES tutor_trainings(id), "sent_training_mail" BOOLEAN NOT NULL DEFAULT FALSE, "trained" BOOLEAN NOT NULL DEFAULT FALSE, "co_tutor_wish" TEXT NOT NULL DEFAULT '', "mentor" BOOLEAN NOT NULL DEFAULT FALSE, + "would_lead_bachelor" BOOLEAN NOT NULL DEFAULT FALSE, + "would_lead_master" BOOLEAN NOT NULL DEFAULT FALSE, "tutorial" INT DEFAULT NULL REFERENCES tutorials(id), "number" TEXT, "notes" TEXT NOT NULL DEFAULT '', @@ -61,8 +68,20 @@ CREATE TABLE IF NOT EXISTS former_tutors ( "trained_date" TEXT NOT NULL DEFAULT '', "years" INT NOT NULL, "last_year" TEXT NOT NULL, - "notes" TEXT, - "lists" TEXT[] NOT NULL DEFAULT '{}' + "notes" TEXT +); + +CREATE TABLE IF NOT EXISTS tutor_lists ( + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tutor_list_entries ( + "list" INT NOT NULL REFERENCES tutor_lists(id), + "email" TEXT NOT NULL, + "notes" TEXT NOT NULL DEFAULT '', + "on_list_since" TEXT NOT NULL, + PRIMARY KEY (list, email) ); CREATE TABLE IF NOT EXISTS rally_station_supervisors ( @@ -151,3 +170,16 @@ CREATE TABLE IF NOT EXISTS master_freshers ( "study_program" TEXT NOT NULL, "aachen_experience" TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS mrx ( + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mrx_entries ( + "id" SERIAL PRIMARY KEY, + "mrx" INT NOT NULL REFERENCES mrx(id), + "time" TIMESTAMP NOT NULL, + "text" TEXT, + "image" TEXT +); diff --git a/src/lib/server/paths.ts b/src/lib/server/paths.ts index 3df4f8e65c3485535a5de04258b5de9c08e232ac..cb238fc0f04ddeab7bd72f6cb2deb1154e418154 100644 --- a/src/lib/server/paths.ts +++ b/src/lib/server/paths.ts @@ -9,7 +9,8 @@ export const imagesPath = path.join(publicPath, "images"); export const uploadsPath = path.join(publicPath, "uploads"); export const flyersPath = path.join(publicPath, "flyer"); export const schedulesPath = path.join(publicPath, "stundenplaene"); +export const tutorFilesPath = path.join(publicPath, "tutorfiles"); -await Promise.all([scheduleFontsPath, imagesPath, uploadsPath, flyersPath, schedulesPath].map(path=>fs.mkdir(path, { recursive: true }))); +await Promise.all([scheduleFontsPath, imagesPath, uploadsPath, flyersPath, schedulesPath, tutorFilesPath].map(path=>fs.mkdir(path, { recursive: true }))); export const configPath = path.join(basePath, "config.json"); diff --git a/src/routes/(non-admin)/tutor/+page.server.ts b/src/routes/(non-admin)/tutor/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..223bbb6144498816a8787560c9f5f3fc0e9b22fb --- /dev/null +++ b/src/routes/(non-admin)/tutor/+page.server.ts @@ -0,0 +1,17 @@ +import { Config } from "$lib/server/config"; +import { TutorTraining } from "$lib/server/database/entities/TutorTraining.entity"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async () => { + const config = Config.get(); + const tutorTrainings = await TutorTraining.getAll(); + return { + fresherWeekStart: config?.fresherWeekStart, + fresherWeekEnd: config?.fresherWeekEnd, + registrationOpen: config?.tutorRegistrationOpen ?? false, + trainings: tutorTrainings.filter(t=>!t.internal).map(t=>({date: t.date, language: t.language})), + semester: config?.currentSemester, + trainingsStart: config?.trainingsStart, + trainingsEnd: config?.trainingsEnd, + }; +}; diff --git a/src/routes/(non-admin)/tutor/+page.svelte b/src/routes/(non-admin)/tutor/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..9abe1dd08d42926caefa6628ed1a6148a08305fd --- /dev/null +++ b/src/routes/(non-admin)/tutor/+page.svelte @@ -0,0 +1,56 @@ +<script> + import TutorFaq from "$lib/components/TutorFAQ.svelte"; + import { Button, P, Heading, List, Li, A } from "flowbite-svelte"; + import { LL, locale } from "$lib/i18n/i18n"; + import LocalizedText from "$lib/components/LocalizedText.svelte"; + import H1 from "$lib/components/H1.svelte"; + + let { data } = $props(); + + let dateFormatter = $derived(new Intl.DateTimeFormat($locale, {day: "2-digit", month: "long", year: "numeric", weekday: "long"})); +</script> + +<H1>{$LL.Tutor.Headline()}</H1> + +<P class="mb-3">{$LL.Tutor.Greeting()}</P> + +<P class="mb-6" whitespace="preline">{$LL.Tutor.Paragraph({semester: data.semester})}</P> + +<Heading tag="h4" class="mb-1">{$LL.Tutor.WhatTitle()}</Heading> +<P class="mb-6" whitespace="preline">{$LL.Tutor.WhatContent()}</P> + +<Heading tag="h4" class="mb-1">{$LL.Tutor.NeedTitle()}</Heading> +<P class="mb-6" whitespace="preline">{$LL.Tutor.NeedContent()}</P> + +<Heading tag="h4" class="mb-1">{$LL.Tutor.WhenTitle()}</Heading> +<P class="mb-1" whitespace="preline"> + {$LL.Tutor.WhenContent({fresherWeek: [new Date(data.fresherWeekStart), new Date(data.fresherWeekEnd)], start: data.trainingsStart, end: data.trainingsEnd})} +</P> +<List tag="ul" class="mb-6"> + {#each data.trainings as training} + <Li class="text-gray-900 dark:text-white">{dateFormatter.format(new Date(training.date))} ({$LL.Navbar.Languages[training.language]()})</Li> + {:else} + <Li class="text-gray-900 dark:text-white">{$LL.Tutor.NoDates()}</Li> + {/each} +</List> + +<P class="mb-6"> + <LocalizedText key="Tutor.FurtherQuestions"> + <A href="mailto:esa@fsmpi.rwth-aachen.de">esa@fsmpi.rwth-aachen.de</A> + </LocalizedText> +</P> + +<P class="mb-6 whitespace-pre-line">{$LL.Tutor.ClosingFormula()}</P> + +<div class="flex mb-6 justify-center"> + {#if data.registrationOpen} + <Button href="/tutor/register/" size="xl"> + <svg aria-hidden="true" class="mr-2 -ml-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> + {$LL.Tutor.RegistrationButton()} + </Button> + {:else} + <Button disabled size="xl">{$LL.Tutor.RegistrationClosed()}</Button> + {/if} +</div> + +<TutorFaq /> diff --git a/src/routes/(non-admin)/tutor/register/+page.server.ts b/src/routes/(non-admin)/tutor/register/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..a093aec8d23cab66bb19ff33a2daba676f2aa6a1 --- /dev/null +++ b/src/routes/(non-admin)/tutor/register/+page.server.ts @@ -0,0 +1,178 @@ +import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types.js"; +import { Tutor } from "$lib/server/database/entities/Tutor.entity.js"; +import { Config } from "$lib/server/config"; +import { TutorTraining } from "$lib/server/database/entities/TutorTraining.entity.js"; +import Gender from "$lib/genders.js"; +import Degree from "$lib/degrees.js"; +import { sendTrainingMail, sendTutorRegisteredMail } from "$lib/server/mail.js"; +import bcrypt from "bcrypt"; +import { TutorStudyProgram } from "$lib/server/database/entities/TutorStudyProgram.entity.js"; + +export const load: PageServerLoad = async ()=>{ + const config = Config.get(); + const studyPrograms = await TutorStudyProgram.getAll(); + const missingTutorCounts = await TutorStudyProgram.getMissingTutors(); + const tutorTrainings = await TutorTraining.getAll(); + return { + tutorTrainings: tutorTrainings.filter(t=>!t.internal).map(t=>({id: t.id, date: t.date, language: t.language, location: t.location, isFull: t.participants.length>=t.maxParticipants})), + registrationOpen: config.tutorRegistrationOpen, + shirtSizes: config.shirtSizes, + fresherWeekStart: config.fresherWeekStart, + studyPrograms: studyPrograms.map(s=>({id: s.id, name: s.name, hasWaitlist: missingTutorCounts[s.id] === null ? false : missingTutorCounts[s.id]! <= 0})), + }; +}; + +export const actions = { + default: async (request)=>{ + const config = Config.get(); + if(!config.tutorRegistrationOpen){ + throw error(403, "Die Tutoranmeldung ist derzeit geschlossen."); + } + const data = await request.request.formData(); + const firstname = data.get("firstname"); + const lastname = data.get("lastname"); + const nickname = data.get("nickname") || ""; + const birthdayString = data.get("birthday"); + const email = data.get("email"); + const phone = data.get("phone"); + const shirtSize = data.get("shirt-size"); + const address = data.get("address"); + const zip = data.get("zip"); + const city = data.get("city"); + const gender = data.get("gender"); + const dietaryRestriction = data.get("dietary-restriction"); + const studyProgramId = Number(data.get("study-program")); + const degree = data.get("degree"); + const wouldLeadBachelor = data.get("would-lead-bachelor") === "on"; + const wouldLeadMaster = data.get("would-lead-master") === "on"; + const trainingString = data.get("training"); + const mentor = !!data.get("mentor"); + const coTutorWish = data.get("co-tutor") || ""; + if(!firstname || !lastname || !email || !phone || !shirtSize || !address || !gender || !degree || !birthdayString || !trainingString){ + throw error(400, "Bitte fülle alle Pflichtfelder aus"); + } + if(!Number.isInteger(studyProgramId) || studyProgramId < 0){ + throw error(422, "Ungültiger Studiengang"); + } + if(typeof email !== "string" || !email.includes("@") || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))){ + throw error(422, "Ungültige E-Mail Adresse"); + } + if(typeof birthdayString !== "string" || !birthdayString.match(/^\d{4}-\d{2}-\d{2}$/)){ + throw error(422, "Ungültiger Geburtstag"); + } + const birthday = Date.parse(birthdayString); + if(isNaN(birthday)){ + throw error(422, "Geburtstag ungültig"); + } + if(typeof dietaryRestriction !== "string"){ // is allowed to be empty, but not null + throw error(422, "Ungültige Essenseinschränkung"); + } + const date = new Date(birthday); + date.setFullYear(date.getFullYear() + 18); + if(new Date(config.fresherWeekStart) < date){ + throw error(422, "Du musst mindestens 18 Jahre alt sein"); + } + if(typeof shirtSize !== "string" || !config.shirtSizes.includes(shirtSize)){ + throw error(422, "Ungültige T-Shirt Größe"); + } + if(typeof gender !== "string" || !(gender in Gender)){ + throw error(422, "Ungültiges Geschlecht"); + } + if(typeof degree !== "string" || !(degree in Degree)){ + throw error(422, "Ungültiger Abschluss"); + } + if(!wouldLeadBachelor && !wouldLeadMaster){ + throw error(422, "Bitte gib an, ob du Bachelor- oder Master-Tutor:in werden möchtest"); + } + if(typeof firstname !== "string" || typeof lastname !== "string" || typeof nickname !== "string" || typeof phone !== "string" || typeof address !== "string" || typeof zip !== "string" || typeof city !== "string" || typeof coTutorWish !== "string"){ + throw error(422, "Ungültige Eingabe"); + } + if(typeof trainingString !== "string"){ + throw error(422, "Ungültige Schulung"); + } + const trainingId = trainingString !== "-" ? Number(trainingString) : null; + if(trainingId !== null && (!Number.isInteger(trainingId) || trainingId < 0)){ + throw error(422, "Ungültige Schulung"); + } + const studyProgram = await TutorStudyProgram.getById(studyProgramId); + if(!studyProgram){ + throw error(422, "Ungültiger Studiengang"); + } + const training = trainingId !== null ? await TutorTraining.getById(trainingId) : null; + if(trainingId !== null && !training){ + throw error(422, "Ungültige Schulung"); + } + const waitlist = ((await TutorStudyProgram.getMissingTutors())[studyProgramId] ?? 1) <= 0; + const birthdate = new Date(birthday).toISOString().slice(0, 10); + const password = Math.random().toString(36).slice(2); + const passwordHash = await bcrypt.hash(password, 10); + const tutorId = await Tutor.create({ + firstname, + lastname, + nickname, + birthday: birthdate, + email, + phone, + shirtSize, + address: `${address}, ${zip} ${city}`, + gender: gender as keyof typeof Gender, + studyProgram: studyProgramId, + degree: degree as keyof typeof Degree, + wouldLeadBachelor, + wouldLeadMaster, + training: trainingId, + mentor, + coTutorWish, + notes: "", + trained: false, + dietaryRestriction, + sentTrainingMail: false, + password: passwordHash, + // TODO add waitlist + }); + const mockTutor: Tutor = { + id: tutorId, + firstname, + lastname, + nickname, + birthday: birthdate, + email, + phone, + shirtSize, + address, + gender: gender as keyof typeof Gender, + studyProgram, + degree: degree as keyof typeof Degree, + wouldLeadBachelor, + wouldLeadMaster, + training, + mentor, + coTutorWish, + notes: "", + trained: false, + dietaryRestriction, + sentTrainingMail: false, + password, + }; + /*sendTutorRegisteredMail(mockTutor).then(()=>{ // no await, doesnt need to block response + if(training){ + const { trainingMailReminderDays } = Config.get(); + const trainingDate = Date.parse(training.date); + const now = Date.now(); + if(trainingDate < now) return; + const daysUntilTraining = (trainingDate - now) / 1000 / 60 / 60 / 24; + if(daysUntilTraining <= trainingMailReminderDays){ + return sendTrainingMail(mockTutor); + } + } + }).catch(err=>{ + console.error(err); + });*/ + return { + success: true, + tutorId, + waitlist, + }; + }, +}; diff --git a/src/routes/(non-admin)/tutor/register/+page.svelte b/src/routes/(non-admin)/tutor/register/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..760517df727a5922e4bbff31de6fb421b4953491 --- /dev/null +++ b/src/routes/(non-admin)/tutor/register/+page.svelte @@ -0,0 +1,203 @@ +<script lang="ts"> + import { enhance } from "$app/forms"; + import { Select, Label, Button, Helper, Checkbox, A, Alert, Input } from "flowbite-svelte"; + import { LL, locale } from "$lib/i18n/i18n"; + import Gender from "$lib/genders"; + import Degree from "$lib/degrees"; + import LocalizedText from "$lib/components/LocalizedText.svelte"; + import { addMessage } from "$lib/messages.js"; + import H1 from "$lib/components/H1.svelte"; + + let { data } = $props(); + + let birthdayError = $state(false); + let ageError = $state(false); + let emailError = $state(-1); + + let success = $state(false); + let error: {message: string}|undefined = $state(); + let waitlist: boolean = $state(false); + + let studyProgramId: number|undefined = $state(undefined); + let studyProgram = $derived(data.studyPrograms.find(sp=>sp.id===studyProgramId)); + + let wouldLeadBachelor = $state(false); + + function validateEmail(email: any) { + if(typeof email !== "string" || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))){ + emailError = 1; + }else{ + if(email.match(/^[a-z]{2}\d{6}@rwth-aachen\.de$/i)) emailError = 0; + else emailError = -1; + } + return emailError; + } + function validateBirthday(date: any){ + const birthday = Date.parse(date); + birthdayError = isNaN(birthday); + ageError = false; + if(!birthdayError){ + let date = new Date(birthday); + date.setFullYear(date.getFullYear()+18); + ageError = new Date(data.fresherWeekStart) < date; + } + return !birthdayError && !ageError; + } + + function isTrainingInFuture(trainingDate: string){ + const date = Date.parse(trainingDate); + const now = Date.now(); + return now + 1000 * 60 * 60 * 24 < date; // can't sign up the day before and later + } +</script> + +<H1>{$LL.Tutor.SignUp.Headline()}</H1> + +{#if data.registrationOpen} +{#if success} +{#if waitlist} +<!-- TODO i18n and proper message --> +<Alert border color="yellow" class="mb-6">Du wurdest zur Warteliste hinzugefügt</Alert> +{:else} +<Alert border color="green" class="mb-6">{$LL.Tutor.SignUp.SignedUpSuccessfully()}</Alert> +{/if} +{/if} +{#if error?.message} +<Alert border color="red" class="mb-6">{error.message}</Alert> +{/if} + +<form method="post" use:enhance={({formElement, formData, cancel})=>{ + if(!validateBirthday(formData.get("birthday")) || validateEmail(formData.get("email")) >= 0) { + addMessage({ type: "error", text: $LL.Tutor.SignUp.FormValidationErrors.InvalidForm() }); + return cancel(); + } + return ({result})=>{ + if(result.type === "success" && result.data?.success) { + waitlist = !!result.data.waitlist; + success = true; + error = undefined; + formElement.reset(); + window.scrollTo({top: 0, behavior: "smooth"}); + }else if(result.type === "error"){ + error = result.error; + window.scrollTo({top: 0, behavior: "smooth"}); + } + }; +}}> + <div class="lg:flex md:gap-2"> + <Label class="mb-2 w-full"> + {$LL.Tutor.SignUp.FirstName()} + <Input type="text" name="firstname" required /> + </Label> + <Label class="mb-2 w-full"> + {$LL.Tutor.SignUp.LastName()} + <Input type="text" name="lastname" required /> + </Label> + </div> + <Label class="mb-2"> + {$LL.Tutor.SignUp.NickName()} + <Input type="text" name="nickname" /> + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.BirthDate()} + <div> + <Input type="date" name="birthday" required on:change={(e)=>validateBirthday((e.target! as HTMLInputElement).value)} /> + {#if birthdayError} + <Helper color="red">{$LL.Tutor.SignUp.InvalidDate()}</Helper> + {:else if ageError} + <Helper color="red">{$LL.Tutor.SignUp.NotOldEnough()}</Helper> + {/if} + </div> + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.Email()} + <Input type="email" name="email" required color={emailError>=0?"red":"base"} on:blur={e=>validateEmail((e.target! as HTMLInputElement).value)} /> + {#if emailError>=0} + <Helper color="red">{$LL.Tutor.SignUp.FormValidationErrors.InvalidEmail[emailError]()}</Helper> + {/if} + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.Phone()} + <Input type="tel" name="phone" required /> + <Helper>{$LL.Tutor.SignUp.PhoneHelper()}</Helper> + </Label> + <div class="md:flex md:gap-2"> + <Label class="mb-2 md:w-full"> + {$LL.Tutor.SignUp.Address()} + <Input name="address" required /> + </Label> + <div class="xs:flex xs:gap-2 w-full"> + <Label class="mb-2 w-full xs:w-40"> + {$LL.Tutor.SignUp.Zip()} + <Input name="zip" required /> + </Label> + <Label class="mb-2 w-full md:w-full"> + {$LL.Tutor.SignUp.City()} + <Input name="city" required /> + </Label> + </div> + </div> + <Label class="mb-2"> + {$LL.Tutor.SignUp.Gender()} + <Select name="gender" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={Object.entries(Gender).map(([value, name])=>({value, name: name[$locale]}))} /> + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.ShirtSize()} + <Select name="shirt-size" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={data.shirtSizes.map(x=>({name:x,value:x}))} /> + <Helper>{$LL.Tutor.SignUp.ShirtSizeHelper()}</Helper> + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.DietaryRestriction()} + <Input type="text" name="dietary-restriction" /> + <Helper>{$LL.Tutor.SignUp.DietaryRestrictionHelper()}</Helper> + </Label> + <div class="lg:flex lg:gap-2"> + <Label class="mb-2 w-full"> + {$LL.Tutor.SignUp.StudyProgram()} + <Select name="study-program" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={data.studyPrograms.map(x=>({name: x.name[$locale], value: x.id}))} bind:value={studyProgramId} /> + {#if studyProgram?.hasWaitlist} + <Helper color="red">{$LL.Tutor.SignUp.StudyProgramWaitlist({studyProgram: studyProgram!.name[$locale]??""})}</Helper> + {/if} + </Label> + <Label class="mb-2 w-full"> + {$LL.Tutor.SignUp.Degree()} + <Select name="degree" required placeholder={$LL.Tutor.SignUp.PleaseChoose()} items={Object.entries(Degree).map(([value, name])=>({value, name: name[$locale]}))} /> + <Helper>{$LL.Tutor.SignUp.DegreeHelper()}</Helper> + </Label> + </div> + <div class="mb-2"> + <Checkbox name="would-lead-bachelor" class="mb-1" bind:checked={wouldLeadBachelor}>{$LL.Tutor.SignUp.WouldLeadBachelor()}</Checkbox> + <Checkbox name="would-lead-master" required={!wouldLeadBachelor}>{$LL.Tutor.SignUp.WouldLeadMaster()}</Checkbox> + </div> + <Label class="mb-2"> + {$LL.Tutor.SignUp.Training()} + <Select name="training" required placeholder={$LL.Tutor.SignUp.PleaseChoose()}> + {#each data.tutorTrainings as t} + {@const date = new Date(t.date)} + {@const language = $LL.Navbar.Languages[t.language]()} + {@const translationFunction = t.isFull ? $LL.Tutor.SignUp.TrainingOptionFull : $LL.Tutor.SignUp.TrainingOption} + <option value={t.date} disabled={!isTrainingInFuture(t.date) || t.isFull}>{translationFunction({date, language})}</option> + {/each} + <option value="-">{$LL.Tutor.SignUp.AlreadyTrained()}</option> + </Select> + </Label> + <Label class="mb-2"> + {$LL.Tutor.SignUp.CoTutorWish()} + <Input name="co-tutor" type="text" /> + <Helper>{$LL.Tutor.SignUp.CoTutorWishHelper()}</Helper> + </Label> + <Checkbox class="mb-2" name="mentor">{$LL.Tutor.SignUp.IsMentor()}</Checkbox> + <Checkbox class="mb-2" required> + <LocalizedText key="Tutor.SignUp.ReadPrivacyPolicy"> + <A class="mx-1" href="/datenschutz" target="_blank">{$LL.Tutor.SignUp.PrivacyPolicy()}</A> + </LocalizedText> + </Checkbox> + <Button type="submit" class="mb-2">{$LL.Tutor.SignUp.Submit()}</Button> +</form> +{:else} +<Alert border color="red"> + <LocalizedText key="Tutor.SignUp.SignUpClosed"> + <A href="mailto:esa@fsmpi.rwth-aachen.de">esa@fsmpi.rwth-aachen.de</A> + </LocalizedText> +</Alert> +{/if} diff --git a/src/routes/admin/studyprogram/+page.server.ts b/src/routes/admin/studyprogram/+page.server.ts index f7875114f921d18bcc92f2565dc237d894e2da35..6414ae1452fe100b4d19cddaaca853a7d90b4f92 100644 --- a/src/routes/admin/studyprogram/+page.server.ts +++ b/src/routes/admin/studyprogram/+page.server.ts @@ -23,12 +23,7 @@ export const actions = { if(typeof n !== "string" || !n) error(400, `Invalid name for locale ${locale}`); name[locale] = n; } - const tutorsWanted = Number(data.get("tutorsWanted") ?? undefined); - if(!Number.isInteger(tutorsWanted) || tutorsWanted < 0) error(400, "Invalid tutors wanted"); - const tutorialNamesString = data.get("tutorialNames"); - if(!tutorialNamesString || typeof tutorialNamesString !== "string") error(400, "Invalid tutorial names"); - const tutorialNames = tutorialNamesString.split(";").map(s=>s.trim()).filter(s=>s); - await StudyProgram.update({id, name, tutorsWanted, tutorialNames}); + await StudyProgram.update({id, name}); }, async createStudyProgram(event){ if(!event.locals.user.permissions.has(Permission.UPDATE_STUDY_PROGRAMS)) error(403, "Insufficient permissions"); @@ -39,11 +34,6 @@ export const actions = { if(typeof n !== "string" || !n) error(400, `Invalid name for locale ${locale}`); name[locale] = n; } - const tutorsWanted = Number(data.get("tutorsWanted")); - if(!Number.isInteger(tutorsWanted) || tutorsWanted < 0) error(400, "Invalid tutors wanted"); - const tutorialNamesString = data.get("tutorialNames"); - if(!tutorialNamesString || typeof tutorialNamesString !== "string") error(400, "Invalid tutorial names"); - const tutorialNames = tutorialNamesString.split(";").map(s=>s.trim()).filter(s=>s); - await StudyProgram.create({name, tutorsWanted, tutorialNames}); + await StudyProgram.create({name}); }, }; diff --git a/src/routes/admin/studyprogram/+page.svelte b/src/routes/admin/studyprogram/+page.svelte index c5dc601ba10afc520aee0c9c9ce2d46adfb3024c..1a7161bfde8bfcfe156ee3e49e9c832ff7a4673b 100644 --- a/src/routes/admin/studyprogram/+page.svelte +++ b/src/routes/admin/studyprogram/+page.svelte @@ -26,7 +26,7 @@ </Breadcrumb> {#each data.studyPrograms as studyProgram (studyProgram.id)} -<form method="post" action="?/updateStudyProgram" id={String(studyProgram.id)} use:enhance={()=>({result, update})=>{ +<form method="post" action="?/updateStudyProgram" id="sp-{studyProgram.id}" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Studiengang gespeichert"}); update({ reset: false }); @@ -38,7 +38,7 @@ <input type="hidden" name="id" value={studyProgram.id} /> </form> {/each} -<form method="post" action="?/createStudyProgram" id="new" use:enhance={()=>({result, update})=>{ +<form method="post" action="?/createStudyProgram" id="sp-new" use:enhance={()=>({result, update})=>{ if(result.type === "success"){ addMessage({type: "success", text: "Studiengang erstellt"}); update(); @@ -51,59 +51,39 @@ <Table> <TableHead> <TableHeadCell>Name</TableHeadCell> - <TableHeadCell>gewünschte Tutorenzahl</TableHeadCell> <TableHeadCell></TableHeadCell> </TableHead> <TableBody> {#each data.studyPrograms as studyProgram (studyProgram.id)} + {#key studyProgram.id} <TableBodyRow> <TableBodyCell> {#each locales as lang} <Label> Name ({lang}) - <Input required name="name[{lang}]" value={studyProgram.name[lang]} form={studyProgram.id} /> + <Input required name="name[{lang}]" value={studyProgram.name[lang]} form="sp-{studyProgram.id}" /> </Label> {/each} </TableBodyCell> <TableBodyCell> - <Label> - gewünschte Tutorenzahl - <Input required type="number" name="tutorsWanted" min={0} value={studyProgram.tutorsWanted} form={studyProgram.id} /> - </Label> - <Label> - Tutoriennamen (mit Semikolon getrennt) - <Input required name="tutorialNames" value={studyProgram.tutorialNames.join(';')} form={studyProgram.id} /> - </Label> - </TableBodyCell> - <TableBodyCell> - <Button type="submit" form={studyProgram.id}>Speichern</Button> + <Button type="submit" form="sp-{studyProgram.id}">Speichern</Button> <Button color="red" on:click={()=>confirmStudyProgramDelete(studyProgram)}>Löschen</Button> </TableBodyCell> </TableBodyRow> + {/key} {/each} {#key "new"} - <TableBodyRow> <TableBodyCell> {#each locales as lang} <Label> Name ({lang}) - <Input required name="name[{lang}]" form="new" /> + <Input required name="name[{lang}]" form="sp-new" /> </Label> {/each} </TableBodyCell> <TableBodyCell> - <Label> - gewünschte Tutorenzahl - <Input required type="number" name="tutorsWanted" min={0} form="new" /> - </Label> - <Label> - Tutoriennamen (mit Semikolon getrennt) - <Input required name="tutorialNames" form="new" /> - </Label> - </TableBodyCell> - <TableBodyCell> - <Button type="submit" form="new">Erstellen</Button> + <Button type="submit" form="sp-new">Erstellen</Button> </TableBodyCell> </TableBodyRow> {/key} diff --git a/src/routes/admin/tutor/+page.server.ts b/src/routes/admin/tutor/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..72f82f9c4378b1d5ce2877c8d66eb0f221144912 --- /dev/null +++ b/src/routes/admin/tutor/+page.server.ts @@ -0,0 +1,18 @@ +import { Permission } from "$lib/perms"; +import { Config } from "$lib/server/config"; +import { TutorStudyProgram } from "$lib/server/database/entities/TutorStudyProgram.entity"; +import { Tutor } from "$lib/server/database/entities/Tutor.entity"; +import { pick } from "$lib/utils"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async event => { + let tutors = await Tutor.getAll(); + const shirtSizes = Config.get().shirtSizes.map(s=>[s, tutors.filter(t=>t.shirtSize===s).length] as const); + if(!event.locals.user.permissions.has(Permission.VIEW_TUTOR_DETAILS)) tutors = tutors.map(t=>pick(t, ["id", "firstname", "lastname", "nickname", "studyProgram", "degree", "wouldLeadBachelor", "wouldLeadMaster", "training", "notes"])); + const studyPrograms = await TutorStudyProgram.getAll(); + return { + tutors: event.locals.user.permissions.has(Permission.VIEW_TUTORS) ? tutors.map(t=>({...t, studyProgram: {...t.studyProgram}, training: t.training ? {...t.training} : null })) : [], + studyPrograms: studyPrograms.map(p=>({...p, tutorCount: tutors.filter(t=>t.studyProgram.id===p.id).length})), + shirtSizes, + }; +}; diff --git a/src/routes/admin/tutor/+page.svelte b/src/routes/admin/tutor/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..14f2f73a72ec571623ce81c8e1de6bc7393d52d4 --- /dev/null +++ b/src/routes/admin/tutor/+page.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + import { Breadcrumb, BreadcrumbItem, Button, Heading, Label, Modal, P, Radio, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; + import { locale } from "$lib/i18n/i18n"; + import { Permission } from "$lib/perms.js"; + import { toCSV } from "$lib/csv"; + import type { Tutor } from "$lib/server/database/entities/Tutor.entity"; + import SortableTable from "$lib/components/SortableTable.svelte"; + import SortableTableHeadCell from "$lib/components/SortableTableHeadCell.svelte"; + + export let data; + + $: dateFormatter = new Intl.DateTimeFormat($locale, { day: "2-digit", month: "2-digit", year: "numeric" }); + + let showExportModal = false; + let exportType = "json"; + const fields: { prop: (t: Tutor)=>unknown, label: string, value: string }[] = [ + { prop: t=>t.firstname, label: "Vorname", value: "firstname" }, + { prop: t=>t.lastname, label: "Nachname", value: "lastname" }, + { prop: t=>t.nickname, label: "Spitzname", value: "nickname" }, + { prop: t=>t.studyProgram.name.de, label: "Fach", value: "studyProgram" }, + { prop: t=>t.training?.date, label: "Schulung", value: "training" }, + { prop: t=>t.notes, label: "Notiz", value: "notes" }, + ...(data.user.permissions.has(Permission.VIEW_TUTOR_DETAILS) ? [ + { prop: t=>t.gender, label: "Geschlecht", value: "gender" }, + { prop: t=>t.mentor, label: "Mentor", value: "mentor" }, + { prop: t=>t.shirtSize, label: "Shirt", value: "shirtSize" }, + { prop: t=>t.coTutorWish, label: "Co-Tutor", value: "coTutorWish" }, + { prop: t=>t.trained, label: "Geschult", value: "trained" }, + { prop: t=>t.degree, label: "Abschluss", value: "degree" }, + { prop: t=>t.email, label: "E-Mail", value: "email" }, + { prop: t=>t.phone, label: "Telefon", value: "phone" }, + { prop: t=>t.address, label: "Adresse", value: "address" }, + { prop: t=>t.birthday, label: "Geburtstag", value: "birthday" }, + { prop: t=>t.dietaryRestriction, label: "Essgewohnheiten", value: "dietaryRestriction" }, + ] satisfies { prop: (t: Tutor)=>unknown, label: string, value: string }[] : []), + ]; + let selectedFields: string[] = fields.map(f => f.value); + function exportTutors(){ + const tutors = data.tutors.map(t=>{ + const tutor: Record<string, unknown> = {}; + for(const field of fields){ + if(selectedFields.includes(field.value)){ + tutor[field.value] = field.prop(t); + } + } + return tutor; + }); + const parsed = exportType === "json" ? JSON.stringify(tutors, null, 2) : toCSV(tutors, selectedFields); + const blob = new Blob([parsed], { type: exportType === "json" ? "application/json" : "text/csv" }); + const url = URL.createObjectURL(blob); + Object.assign(document.createElement("a"), { + href: url, + download: `tutors.${exportType}`, + }).click(); + URL.revokeObjectURL(url); + } + + let showMatchModal = false; + function matchTutors(){ + + } + function matchRemainingTutors(){ + + } +</script> + +<Breadcrumb class="mb-4"> + <BreadcrumbItem href="/admin" home>Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor">Tutoren</BreadcrumbItem> +</Breadcrumb> + +{#if data.user.permissions.has(Permission.VIEW_TUTORS)} +<Modal bind:open={showExportModal} autoclose outsideclose title="Tutoren exportieren" classBody="space-y-1"> + <Heading tag="h2" customSize="text-2xl font-bold">Format</Heading> + <Radio bind:group={exportType} value="json">JSON</Radio> + <Radio bind:group={exportType} value="csv">CSV</Radio> + <Heading tag="h2" customSize="text-2xl font-bold">Felder</Heading> + {#each fields as field} + <Label class="text-sm rtl:text-right font-medium text-gray-900 dark:text-gray-300 flex items-center"> + <input type="checkbox" value={field.value} bind:group={selectedFields} class="w-4 h-4 bg-gray-100 border-gray-300 dark:ring-offset-gray-800 focus:ring-2 me-2 dark:bg-gray-600 dark:border-gray-500 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600" /> + {field.label} + </Label> + {/each} + <svelte:fragment slot="footer"> + <Button on:click={exportTutors}>Exportieren</Button> + <Button color="light">Abbrechen</Button> + </svelte:fragment> +</Modal> +{#if data.user.permissions.has(Permission.UPDATE_TUTOR)} +<Modal bind:open={showMatchModal} autoclose outsideclose title="Co-Tutoren automatisch zuweisen" classBody="space-y-1"> + <Heading tag="h2" customSize="text-2xl font-bold">Wünsche erkennen</Heading> + <P class="mb-2"> + Hier können Co-Tutoren automatisch anhand ihrer Wünsche zugeordnet werden. Tutoren, die sich gegenseitig als Co-Tutor wünschen, werden einander zugeordnet. Wenn kein Wunsch erkannt wird oder der Wunsch nicht automatisch zugewiesen werden kann, wird der Tutor <i>nicht</i> zugeordnet. + </P> + <P class="mb-2" color="text-red-700 dark:text-red-500"> + <b>Vorsicht:</b> Alle bisherigen Zuordnungen werden gelöscht und durch die automatischen Zuordnungen ersetzt. + </P> + <Heading tag="h2" customSize="text-2xl font-bold">Verbleibende Tutoren zuweisen</Heading> + <P class="mb-2"> + Hier werden soweit möglich alle Tutoren, die noch keinem Tutorium zugeordnet sind, zufällig zugeordnet. Bitte prüfe vorher, dass alle Wünsche berücksichtigt sind. + </P> + <svelte:fragment slot="footer"> + <Button on:click={matchTutors}>Wünsche erkennen</Button> + <Button on:click={matchRemainingTutors}>Verbleibende Tutoren zuweisen</Button> + <Button color="light">Abbrechen</Button> + </svelte:fragment> +</Modal> +{/if} + +<div class="flex gap-2 mb-3"> + {#if data.user.permissions.has(Permission.UPDATE_TUTOR)} + <Button href="/admin/tutor/new">Neuer Tutor</Button> + <Button on:click={()=>showMatchModal=true}>Tutorien zuweisen</Button> + {/if} + <Button on:click={()=>showExportModal=true}>Exportieren</Button> +</div> +{/if} + +<Table class="mb-6"> + <TableHead> + {#each data.studyPrograms as studyProgram} + <TableHeadCell>{studyProgram.name[$locale]}</TableHeadCell> + {/each} + <TableHeadCell>Gesamt</TableHeadCell> + </TableHead> + <TableBody> + <TableBodyRow> + {#each data.studyPrograms as studyProgram} + <TableBodyCell>{studyProgram.tutorCount}{#if studyProgram.tutorsWanted>0}{" von "}{studyProgram.tutorsWanted}{/if}</TableBodyCell> + {/each} + <TableBodyCell>{data.studyPrograms.map(p=>p.tutorCount).reduce((a,b)=>a+b, 0)}</TableBodyCell> + </TableBodyRow> + </TableBody> +</Table> + +<Table class="mb-6"> + <TableHead> + {#each data.shirtSizes as size} + <TableHeadCell>{size[0]}</TableHeadCell> + {/each} + </TableHead> + <TableBody> + <TableBodyRow> + {#each data.shirtSizes as size} + <TableBodyCell>{size[1]}</TableBodyCell> + {/each} + </TableBodyRow> + </TableBody> +</Table> + +{#if data.user.permissions.has(Permission.VIEW_TUTORS)} +<SortableTable class="mb-6" hoverable items={data.tutors}> + <TableHead> + <SortableTableHeadCell sort={(a: Tutor, b)=>a.firstname.localeCompare(b.firstname)}>Vorname</SortableTableHeadCell> + <SortableTableHeadCell sort={(a: Tutor, b)=>a.lastname.localeCompare(b.lastname)}>Nachname</SortableTableHeadCell> + <SortableTableHeadCell sort={(a: Tutor, b)=>a.studyProgram.name[$locale].localeCompare(b.studyProgram.name[$locale]) || a.degree.localeCompare(b.degree)}>Fach</SortableTableHeadCell> + <SortableTableHeadCell sort={(a: Tutor, b)=>a.coTutorWish.localeCompare(b.coTutorWish)}>Co-Tutor</SortableTableHeadCell> + <SortableTableHeadCell sort={(a: Tutor, b)=>{ + if(a.training && b.training) return a.training.date.localeCompare(b.training.date); + else if(a.training) return -1; + else if(b.training) return 1; + else return 0; + }}>Schulung</SortableTableHeadCell> + <SortableTableHeadCell sort={(a: Tutor, b)=>a.notes.localeCompare(b.notes)}>Notiz</SortableTableHeadCell> + {#if data.user.permissions.has(Permission.UPDATE_TUTOR)} + <TableHeadCell></TableHeadCell> + {/if} + </TableHead> + {#snippet row({item: tutor})} + <TableBodyRow> + <TableBodyCell>{tutor.firstname}{tutor.nickname?` (${tutor.nickname})`:""}</TableBodyCell> + <TableBodyCell>{tutor.lastname}</TableBodyCell> + <TableBodyCell>{tutor.studyProgram.name[$locale]} {tutor.degree} {#if tutor.wouldLeadBachelor}{#if tutor.wouldLeadMaster}<span title="Würde ein Bachelor- oder Master-Tutorium übernehmen">(B/M)</span>{:else}<span title="Würde ein Bachelor-Tutorium übernehmen">(B)</span>{/if}{:else}<span title="Würde ein Master-Tutorium übernehmen">(M)</span>{/if}</TableBodyCell> + <TableBodyCell>{tutor.coTutorWish || ""}</TableBodyCell> + <TableBodyCell>{tutor.training?dateFormatter.format(new Date(tutor.training.date)):"Bereits geschult"}</TableBodyCell> + <TableBodyCell>{tutor.notes}</TableBodyCell> + {#if data.user.permissions.has(Permission.UPDATE_TUTOR)} + <TableBodyCell> + <Button href={`/admin/tutor/${tutor.id}`} color="light" size="xs">Bearbeiten</Button> + </TableBodyCell> + {/if} + </TableBodyRow> + {/snippet} +</SortableTable> +{/if} diff --git a/src/routes/admin/tutor/[id=number]/+page.server.ts b/src/routes/admin/tutor/[id=number]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3fe8bb0a057b4a09e981dddec3d4c11cab11c03 --- /dev/null +++ b/src/routes/admin/tutor/[id=number]/+page.server.ts @@ -0,0 +1,90 @@ +import Degree from "$lib/degrees"; +import Gender from "$lib/genders.js"; +import { Config } from "$lib/server/config"; +import { TutorStudyProgram } from "$lib/server/database/entities/TutorStudyProgram.entity.js"; +import { Tutor } from "$lib/server/database/entities/Tutor.entity.js"; +import { TutorTraining } from "$lib/server/database/entities/TutorTraining.entity.js"; +import { error } from "@sveltejs/kit"; + +export const load = async (request) => { + const id = Number(request.params.id); + if(isNaN(id)) error(400, "Bad Request"); + const tutor = await Tutor.getById(id); + if(!tutor) error(404, "Not Found"); + const trainings = await TutorTraining.getAll(); + const studyPrograms = await TutorStudyProgram.getAll(); + return { + tutor: {...tutor, studyProgram: {...tutor.studyProgram}, training: tutor.training ? {...tutor.training} : null}, + trainings: trainings.map(t=>({...t, participants: t.participants.length})), + shirtSizes: Config.get().shirtSizes, + studyPrograms: studyPrograms.map(s=>({...s})), + }; +}; + +export const actions = { + default: async (request) => { + const tutorId = parseInt(request.params.id); + const tutor = await Tutor.getById(tutorId); + if(!tutor) error(404, "Not Found"); + const data = await request.request.formData(); + const { shirtSizes } = Config.get(); + const firstname = data.get("firstname"); + const lastname = data.get("lastname"); + const nickname = data.get("nickname"); + const birthday = data.get("birthday"); + const email = data.get("email"); + const phone = data.get("phone"); + const shirtSize = data.get("shirtSize"); + const address = data.get("address"); + const gender = data.get("gender"); + const studyProgramString = data.get("studyProgram"); + const degree = data.get("degree"); + const trainingId = data.get("training"); + const trained = data.get("trained") === "on"; + const mentor = data.get("mentor") === "on"; + const coTutorWish = data.get("coTutorWish"); + const notes = data.get("notes"); + if(!firstname || typeof firstname !== "string") error(400, "Invalid firstname"); + if(!lastname || typeof lastname !== "string") error(400, "Invalid lastname"); + if(nickname !== null && typeof nickname !== "string") error(400, "Invalid nickname"); + if(!birthday || typeof birthday !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(birthday)) error(400, "Invalid birthday"); + if(isNaN(Date.parse(birthday))) error(400, "Invalid birthday"); + if(!email || typeof email !== "string" || !email.includes("@") || !(email.endsWith("@rwth-aachen.de") || email.endsWith(".rwth-aachen.de"))) error(400, "Invalid email"); + if(!phone || typeof phone !== "string") error(400, "Invalid phone"); + if(!shirtSize || typeof shirtSize !== "string" || !shirtSizes.includes(shirtSize)) error(400, "Invalid shirtSize"); + if(!address || typeof address !== "string") error(400, "Invalid address"); + if(typeof gender !== "string" || !(gender in Gender)) error(400, "Invalid gender"); + if(typeof studyProgramString !== "string" || !/^[1-9]+\d*$/.test(studyProgramString)) error(400, "Invalid studyProgram"); + const studyProgramId = Number(studyProgramString); + const studyProgram = await TutorStudyProgram.getById(studyProgramId); + if(!studyProgram) error(400, "Invalid studyProgram"); + if(typeof degree !== "string" || !(degree in Degree)) error(400, "Invalid degree"); + if(!trainingId || typeof trainingId !== "string" || !/^[1-9]+\d*$|^-$/.test(trainingId)) error(400, "Invalid training"); + const training = trainingId === "-" ? null : await TutorTraining.getById(Number(trainingId)); + if(trainingId !== "-" && !training) error(400, "Invalid training"); + if(typeof coTutorWish !== "string") error(400, "Invalid coTutorWish"); + if(typeof notes !== "string") error(400, "Invalid comment"); + const sentTrainingMail = tutor.sentTrainingMail && tutor.training?.id === training?.id; + // eslint-disable-next-line no-unused-vars + const updatedTutor = await Tutor.update({ + id: tutor.id, + firstname, + lastname, + nickname, + birthday, + email, + phone, + shirtSize, + address, + gender: gender as keyof typeof Gender, + studyProgram: studyProgram.id, + degree: degree as keyof typeof Degree, + training: training?.id ?? null, + trained, + mentor, + coTutorWish, + notes, + sentTrainingMail, + }); + }, +}; diff --git a/src/routes/admin/tutor/[id=number]/+page.svelte b/src/routes/admin/tutor/[id=number]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..738f68d242c49c84f100ca1b3ad929460448ca60 --- /dev/null +++ b/src/routes/admin/tutor/[id=number]/+page.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import { Breadcrumb, BreadcrumbItem, Button, Checkbox, Input, Label, Select, Textarea } from "flowbite-svelte"; + import { locale } from "$lib/i18n/i18n"; + import Gender from "$lib/genders"; + import Degree from "$lib/degrees"; + import { enhance } from "$app/forms"; + import { addMessage } from "$lib/messages.js"; + + let { data } = $props(); + + let dateFormatter = $derived(new Intl.DateTimeFormat($locale, { day: "2-digit", month: "2-digit", year: "numeric" })); +</script> + +<Breadcrumb class="mb-4"> + <BreadcrumbItem href="/admin" home>Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor">Tutoren</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor/{data.tutor.id}">{data.tutor.firstname} {data.tutor.lastname}</BreadcrumbItem> +</Breadcrumb> + +<form method="post" use:enhance={()=>({result, update})=>{ + if(result.type === "success"){ + addMessage({type: "success", text: "Tutor gespeichert"}); + update({ reset: false }); + }else{ + addMessage({type: "error", text: "Fehler beim Speichern"}); + console.error(result); + } +}}> + <Label class="mb-2"> + Vorname + <Input name="firstname" value={data.tutor.firstname} /> + </Label> + <Label class="mb-2"> + Nachname + <Input name="lastname" value={data.tutor.lastname} /> + </Label> + <Label class="mb-2"> + Rufname + <Input name="nickname" value={data.tutor.nickname} /> + </Label> + <Label class="mb-2"> + Geburtstag + <Input name="birthday" value={data.tutor.birthday} type="date" /> + </Label> + <Label class="mb-2"> + Handynummer + <Input name="phone" value={data.tutor.phone} /> + </Label> + <Label class="mb-2"> + E-Mail + <Input name="email" value={data.tutor.email} /> + </Label> + <Label class="mb-2"> + Adresse + <Input name="address" value={data.tutor.address} /> + </Label> + <Label class="mb-2"> + Geschlecht + <Select name="gender" value={data.tutor.gender} items={Object.entries(Gender).map(([value, name])=>({value, name: name[$locale]}))} /> + </Label> + <Label class="mb-2"> + T-Shirt-Größe + <Select name="shirtSize" value={data.tutor.shirtSize} items={data.shirtSizes.map(s=>({value:s,name:s}))} /> + </Label> + <Label class="mb-2"> + Studiengang + <Select name="studyProgram" value={data.tutor.studyProgram.id} items={data.studyPrograms.map(p=>({value:p.id,name:p.name[$locale]}))} /> + </Label> + <Label class="mb-2"> + Abschluss + <Select name="degree" value={data.tutor.degree} items={Object.entries(Degree).map(([value, name])=>({value, name: name[$locale]}))} /> + </Label> + <Checkbox class="mb-2" name="trained" checked={data.tutor.trained}>Geschult</Checkbox> + <Label class="mb-2"> + Schulung + <Select name="training" value={data.tutor.training?.date ?? "-"} items={[{name:"Bereits geschult",value:"-"}, ...data.trainings.map(t=>({value:t.date,name:`${dateFormatter.format(new Date(t.date))} (${t.language})`}))]} /> + </Label> + <Label class="mb-2"> + Co-Tutor-Wunsch + <Input name="coTutorWish" value={data.tutor.coTutorWish} /> + </Label> + <Label class="mb-2"> + Notiz + <Textarea name="notes" value={data.tutor.notes} /> + </Label> + <Checkbox class="mb-2" name="mentor" checked={data.tutor.mentor}>Informatik Mentor</Checkbox> + <Button type="submit" color="primary">Speichern</Button> +</form> diff --git a/src/routes/admin/tutor/training/+page.server.ts b/src/routes/admin/tutor/training/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..07d962542d9b945a7aa9990ebf80e0aa0aef1196 --- /dev/null +++ b/src/routes/admin/tutor/training/+page.server.ts @@ -0,0 +1,9 @@ +import { TutorTraining } from '$lib/server/database/entities/TutorTraining.entity'; +import type { PageServerLoad } from './$types'; + +export const load = (async () => { + const trainings = await TutorTraining.getAll(); + return { + trainings: trainings.map(t => ({...t, participants: t.participants.length})).sort((a,b)=>a.date.localeCompare(b.date)), + }; +}) satisfies PageServerLoad; diff --git a/src/routes/admin/tutor/training/+page.svelte b/src/routes/admin/tutor/training/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..5e80e64f2b0dc1f1fe4b17e68e900ce1a903fdb8 --- /dev/null +++ b/src/routes/admin/tutor/training/+page.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import LL, { locale } from "$lib/i18n/i18n"; + import { Permission } from "$lib/perms"; + import { Breadcrumb, BreadcrumbItem, Button, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from "flowbite-svelte"; + + let { data } = $props(); +</script> + +<Breadcrumb class="mb-4"> + <BreadcrumbItem href="/admin" home>Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor">Tutoren</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor/training">Schulungen</BreadcrumbItem> +</Breadcrumb> + +<Table> + <TableHead> + <TableHeadCell>Datum</TableHeadCell> + <TableHeadCell>Ort</TableHeadCell> + <TableHeadCell>Sprache</TableHeadCell> + <TableHeadCell>Anmeldungen</TableHeadCell> + <TableHeadCell>Anmerkung</TableHeadCell> + {#if data.user.permissions.has(Permission.EDIT_TRAININGS)} + <TableHeadCell></TableHeadCell> + {/if} + </TableHead> + <TableBody> + {#each data.trainings as training} + <TableBodyRow class={training.internal ? "text-gray-700 dark:text-gray-300" : null}> + <TableBodyCell>{new Intl.DateTimeFormat($locale, {year: "numeric", month: "2-digit", day: "2-digit"}).format(Date.parse(training.date))}</TableBodyCell> + <TableBodyCell>{training.location}</TableBodyCell> + <TableBodyCell>{$LL.Navbar.Languages[training.language]()}</TableBodyCell> + <TableBodyCell>{training.participants} / {training.maxParticipants}</TableBodyCell> + <TableBodyCell>{training.notes}</TableBodyCell> + {#if data.user.permissions.has(Permission.EDIT_TRAININGS)} + <TableBodyCell> + <Button href="/admin/tutor/training/{training.id}">Bearbeiten</Button> + <Button color="red">-</Button> + </TableBodyCell> + {/if} + </TableBodyRow> + {/each} + {#if data.user.permissions.has(Permission.EDIT_TRAININGS)} + <TableBodyRow> + <TableBodyCell colspan="5"> + <Button href="/admin/tutor/training/new" class="w-20 block mx-auto">+</Button> + </TableBodyCell> + </TableBodyRow> + {/if} + </TableBody> +</Table> diff --git a/src/routes/admin/tutor/training/[id=number]/+page.server.ts b/src/routes/admin/tutor/training/[id=number]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b556afe9da51d6428368b5b9c4cb5056469c1a7 --- /dev/null +++ b/src/routes/admin/tutor/training/[id=number]/+page.server.ts @@ -0,0 +1,47 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { TutorTraining } from '$lib/server/database/entities/TutorTraining.entity'; +import { locales, type Locale } from '$lib/i18n/i18n'; + +export const load = (async event => { + const id = Number(event.params.id); + if(!Number.isInteger(id) || id < 1) error(404, 'Not Found'); + const training = await TutorTraining.getById(id); + if(!training) error(404, 'Not Found'); + return { + training: {...training, participants: training.participants.length}, + }; +}) satisfies PageServerLoad; + +export const actions = { + update: async event => { + const id = Number(event.params.id); + if(!Number.isInteger(id) || id < 1) error(404, 'Not Found'); + const training = await TutorTraining.getById(id); + if(!training) error(404, 'Not Found'); + const data = await event.request.formData(); + const dateStr = data.get("date"); + const location = data.get("location"); + const language = data.get("language"); + const maxParticipantsStr = data.get("maxParticipants"); + const notes = data.get("notes"); + const internal = data.get("internal") === "on"; + if(typeof dateStr !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) error(400, 'Invalid date'); + const dateNum = Date.parse(dateStr); + if(isNaN(dateNum)) error(400, 'Invalid date'); + if(typeof location !== "string" || location.length < 1) error(400, 'Invalid location'); + if(typeof language !== "string" || !locales.includes(language as any)) error(400, 'Invalid language'); + if(typeof maxParticipantsStr !== "string" || !/^\d+$/.test(maxParticipantsStr)) error(400, 'Invalid maxParticipants'); + const maxParticipants = Number(maxParticipantsStr); + if(!Number.isInteger(maxParticipants) || maxParticipants < 1) error(400, 'Invalid maxParticipants'); + if(typeof notes !== "string") error(400, 'Invalid notes'); + await TutorTraining.update(id, { + date: dateStr, + location, + language: language as Locale, + maxParticipants, + notes, + internal, + }); + }, +}; diff --git a/src/routes/admin/tutor/training/[id=number]/+page.svelte b/src/routes/admin/tutor/training/[id=number]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..af4108c91331e353636fd293034f676ad2c46e03 --- /dev/null +++ b/src/routes/admin/tutor/training/[id=number]/+page.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { goto } from '$app/navigation'; + import LL, { locales } from '$lib/i18n/i18n.js'; + import { addMessage } from '$lib/messages.js'; + import { Label, Textarea, Checkbox, Button, Input, Select } from 'flowbite-svelte'; + + let { data } = $props(); +</script> + +<form action="?/update" method="post" use:enhance={()=>({result})=>{ + if(result.type === "success"){ + addMessage({type: "success", text: "Schulung gespeichert"}); + goto("/admin/tutor/training"); + }else{ + addMessage({type: "error", text: "Fehler beim Speichern"}); + console.error(result); + } +}}> + <Label class="mb-2"> + Datum + <Input type="date" name="date" value={data.training.date} required /> + </Label> + <Label class="mb-2"> + Ort + <Input type="text" name="location" value={data.training.location} required /> + </Label> + <Label class="mb-2"> + Sprache + <Select name="language" value={data.training.language} required> + {#each locales as locale} + <option value={locale}>{$LL.Navbar.Languages[locale]()}</option> + {/each} + </Select> + </Label> + <Label class="mb-2"> + Maximale Teilnehmer + <Input type="number" name="maxParticipants" min="1" value={data.training.maxParticipants} required /> + </Label> + <Label class="mb-2"> + Anmerkung + <Textarea type="text" name="notes" value={data.training.notes} /> + </Label> + <Checkbox name="internal" class="mb-2" value="on" checked={data.training.internal}>Intern (Tutoren sehen die Schulung nicht und können sich nicht selbst dafür anmelden)</Checkbox> + <Button type="submit" class="mb-2">Speichern</Button> +</form> diff --git a/src/routes/admin/tutor/training/new/+page.server.ts b/src/routes/admin/tutor/training/new/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..289d07e21e2fd78f477856db0e83856048a9ff83 --- /dev/null +++ b/src/routes/admin/tutor/training/new/+page.server.ts @@ -0,0 +1,27 @@ +import { locales, type Locale } from '$lib/i18n/i18n.js'; +import { Permission } from '$lib/perms'; +import { TutorTraining } from '$lib/server/database/entities/TutorTraining.entity.js'; +import { error } from '@sveltejs/kit'; + +export const actions = { + create: async event => { + if(!event.locals.user.permissions.has(Permission.EDIT_TRAININGS)) error(403, "You don't have permission to create trainings"); + const data = await event.request.formData(); + const date = data.get('date'); + const location = data.get('location'); + const language = data.get('language'); + const maxParticipantsRaw = data.get('maxParticipants'); + const notes = data.get('notes'); + const internal = data.get('internal'); + if(typeof date !== "string" || !date.match(/^\d{4}-\d{2}-\d{2}$/) || isNaN(Date.parse(date))) error(400, "Invalid date"); + if(!location || typeof location !== "string") error(400, "Invalid location"); + if(typeof language !== "string" || !locales.includes(language as any)) error(400, "Invalid language"); + if(!maxParticipantsRaw || typeof maxParticipantsRaw !== "string") error(400, "Invalid maxParticipants"); + const maxParticipants = Number(maxParticipantsRaw); + if(!Number.isInteger(maxParticipants) || maxParticipants < 0) error(400, "Invalid maxParticipants"); + if(typeof notes !== "string") error(400, "Invalid notes"); + if(internal !== null && internal !== "on") error(400, "Invalid internal"); + const id = await TutorTraining.create({ date, location, language: language as Locale, maxParticipants, notes, internal: internal === "on" }); + return { id }; + }, +}; diff --git a/src/routes/admin/tutor/training/new/+page.svelte b/src/routes/admin/tutor/training/new/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..9b5cef1a212935cf238613c84f213f7d481e387f --- /dev/null +++ b/src/routes/admin/tutor/training/new/+page.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import { enhance } from "$app/forms"; + import { goto } from "$app/navigation"; + import LL, { locales } from "$lib/i18n/i18n"; + import { addMessage } from "$lib/messages"; + import { Breadcrumb, BreadcrumbItem, Button, Checkbox, Input, Label, Select, Textarea } from "flowbite-svelte"; +</script> + +<Breadcrumb class="mb-4"> + <BreadcrumbItem href="/admin" home>Admin</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor">Tutoren</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor/training">Schulungen</BreadcrumbItem> + <BreadcrumbItem href="/admin/tutor/training/new">Neue Schulung</BreadcrumbItem> +</Breadcrumb> + +<form action="?/create" method="post" use:enhance={()=>({result})=>{ + if(result.type === "success"){ + addMessage({type: "success", text: "Schulung erstellt"}); + goto("/admin/tutor/training"); + }else{ + addMessage({type: "error", text: "Fehler beim Erstellen"}); + console.error(result); + } +}}> + <Label class="mb-2"> + Datum + <Input type="date" name="date" required /> + </Label> + <Label class="mb-2"> + Ort + <Input type="text" name="location" required /> + </Label> + <Label class="mb-2"> + Sprache + <Select name="language" required> + {#each locales as locale} + <option value={locale}>{$LL.Navbar.Languages[locale]()}</option> + {/each} + </Select> + </Label> + <Label class="mb-2"> + Maximale Teilnehmer + <Input type="number" name="maxParticipants" min="1" required /> + </Label> + <Label class="mb-2"> + Anmerkung + <Textarea type="text" name="notes" /> + </Label> + <Checkbox name="internal" class="mb-2" value="on">Intern (Tutoren sehen die Schulung nicht und können sich nicht selbst dafür anmelden)</Checkbox> + <Button type="submit" class="mb-2">Erstellen</Button> +</form>