diff --git a/client/dist.zip b/client/dist.zip index a09eaf598ae56db5b645a15b5fe74efa15e0463e..0560848a89e5d8fae771c5c8a89fd8985f69ec20 100644 Binary files a/client/dist.zip and b/client/dist.zip differ diff --git a/client/prepare_deploy.sh b/client/prepare_deploy.sh index e99f3bdfd2ba6fbefcac51771fb05706126373db..1d72534757fcf6948fcfa649f4e35873c5c26692 100644 --- a/client/prepare_deploy.sh +++ b/client/prepare_deploy.sh @@ -1,2 +1,2 @@ bun run build -zip -r dist.zip dist/ +zip -r dist.zip dist/* diff --git a/client/src/components/DownloadPdf.tsx b/client/src/components/DownloadPdf.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23496729d436cb67877ce533ebed2f6305525321 --- /dev/null +++ b/client/src/components/DownloadPdf.tsx @@ -0,0 +1,28 @@ +interface PropPDF { + base64: string + file_name: string +} + +export default function DownloadPdf(props: PropPDF) { + const pdfBlobUrl = (base64: string) => { + const byteCharacters = atob(base64) // Decode Base64 + const byteNumbers = Array.from(byteCharacters, (char) => + char.charCodeAt(0), + ) + const byteArray = new Uint8Array(byteNumbers) + const blob = new Blob([byteArray], { type: "application/pdf" }) + return URL.createObjectURL(blob) + } + + return ( + <a + href={pdfBlobUrl(props.base64)} + download={props.file_name} + class="text-blue-500" + target="_blank" + rel="noopener noreferrer" + > + Download PDF + </a> + ) +} diff --git a/client/src/components/ParticipantAdditionnalInfo.tsx b/client/src/components/ParticipantAdditionnalInfo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a2d6266f362a07d1010a43519a9505d0e72f716 --- /dev/null +++ b/client/src/components/ParticipantAdditionnalInfo.tsx @@ -0,0 +1,188 @@ +import { useParams } from "@solidjs/router" +import { createResource } from "solid-js" +import { getParticipant } from "../request/routes" +import DownloadPdf from "./DownloadPdf" + +export default function ParticipantAdditionnalInfo() { + const params = useParams() + + const fetch = async () => { + const id = params.id + if (!id) return + const p = await getParticipant(id) + console.log(p) + return p + } + + const [user] = createResource(fetch) + + return ( + <div class="flex items-center justify-center px-4 pt-16"> + {user() && ( + <div class="w-full rounded-lg bg-white"> + <h1 class="mb-4 text-2xl font-bold"> + Participant Information + </h1> + + <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* Personal Info */} + <div> + <h2 class="mb-2 text-xl font-semibold"> + Personal Info + </h2> + <p> + <span class="font-medium">First Name:</span>{" "} + {user().first_name} + </p> + <p> + <span class="font-medium">Last Name:</span>{" "} + {user().last_name} + </p> + <p> + <span class="font-medium">Email:</span>{" "} + {user().email} + </p> + <p> + <span class="font-medium">Phone Number:</span>{" "} + {user().phone_number} + </p> + <p> + <span class="font-medium">Pronouns:</span>{" "} + {user().pronouns} + </p> + <p> + <span class="font-medium">T-Shirt Size:</span>{" "} + {user().tshirt_size} + </p> + <p> + <span class="font-medium">Comments:</span>{" "} + {user().comments} + </p> + </div> + + {/* Competition Info */} + <div> + <h2 class="mb-2 text-xl font-semibold"> + Competition Info + </h2> + <p> + <span class="font-medium">Role:</span>{" "} + {user().role} + </p> + <p> + <span class="font-medium">Competition:</span>{" "} + {user().competition} + </p> + <p> + <span class="font-medium">University:</span>{" "} + {user().university} + </p> + <p> + <span class="font-medium"> + Food Form Completed: + </span>{" "} + {user().food_forms_completed ? "Yes" : "No"} + </p> + <p> + <span class="font-medium"> + Dietary Restrictions: + </span>{" "} + {user().dietary_restrictions} + </p> + <p> + <span class="font-medium">Allergies:</span>{" "} + {user().allergies} + </p> + <p> + <span class="font-medium">Supper:</span>{" "} + {user().supper} + </p> + </div> + + {/* Emergency Info */} + <div> + <h2 class="mb-2 text-xl font-semibold"> + Emergency Info + </h2> + <p> + <span class="font-medium"> + Emergency Contact Name: + </span>{" "} + {user().emergency_contact_name} + </p> + <p> + <span class="font-medium"> + Emergency Contact Phone: + </span>{" "} + {user().emergency_contact_phone} + </p> + <p> + <span class="font-medium">Relationship:</span>{" "} + {user().emergency_contact_relationship} + </p> + <p> + <span class="font-medium"> + Medical Conditions: + </span>{" "} + {user().medical_conditions} + </p> + <p> + <span class="font-medium"> + Reduced Mobility: + </span>{" "} + {user().reduced_mobility} + </p> + </div> + + {/* Files */} + <div> + <h2 class="mb-2 text-xl font-semibold"> + Uploaded Documents + </h2> + <p> + <span class="font-medium">Study Proof:</span>{" "} + <DownloadPdf + base64={user().study_proof} + file_name={ + user().first_name + + "_" + + user().last_name + + "_study_proof.pdf" + } + /> + </p> + <p> + <span class="font-medium">Photo:</span>{" "} + <img + width="256" + height="256" + src={ + "data:image/png;base64," + user().photo + } + ></img> + </p> + <p> + <span class="font-medium">CV:</span>{" "} + <DownloadPdf + base64={user().cv} + file_name={ + user().first_name + + "_" + + user().last_name + + "_cv.pdf" + } + /> + </p> + <p> + <span class="font-medium"> + Monthly Opus Card: + </span>{" "} + {user().has_monthly_opus_card ? "Yes" : "No"} + </p> + </div> + </div> + </div> + )} + </div> + ) +} diff --git a/client/src/components/forms/AdditionnalInfoForm.tsx b/client/src/components/forms/AdditionnalInfoForm.tsx index f983bce6739385d2be6f2a3544bb1e699e39d9b7..97e15eddab4d79fa54e5435edd1134c133767b08 100644 --- a/client/src/components/forms/AdditionnalInfoForm.tsx +++ b/client/src/components/forms/AdditionnalInfoForm.tsx @@ -14,6 +14,31 @@ import { locale, t } from "../../stores/locale" import { SubmitError } from "../forms-component/SubmitError" import { SubmitSuccess } from "../forms-component/SubmitSuccess" import { YesNo } from "../forms-component/YesNo" +import DownloadPdf from "../DownloadPdf" +function getBase64(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + // Event listener when the file reading is completed + reader.onload = () => { + // `reader.result` contains the base64 string + const base64String = reader.result as string + const base64StringSplit = base64String.split(",")[1] + if (!base64StringSplit) { + reject(new Error("Failed to convert file to base64")) + } + resolve(base64StringSplit) // Removing the base64 prefix + } + + // Event listener for any error + reader.onerror = () => { + reject(new Error("Failed to convert file to base64")) + } + + // Reading the file as a data URL (which contains base64 encoded string) + reader.readAsDataURL(file) + }) +} export function AdditionalInfoForm() { const [loginForm, { Form, Field }] = createForm<ParticipantInfo>() @@ -21,15 +46,28 @@ export function AdditionalInfoForm() { const [success, setSuccess] = createSignal<string | null>(null) const handleSubmit: SubmitHandler<ParticipantInfo> = async ( - values, + info, event, ) => { event.preventDefault() - const response = await patchParticipantInfo(values) - if (response && response.status == 201) { + const payload: any = info + if (info.study_proof) { + payload.study_proof = await getBase64(info.study_proof) + } + if (info.photo) { + payload.photo = await getBase64(info.photo) + } + if (info.cv) { + payload.cv = await getBase64(info.cv) + } + const response = await patchParticipantInfo(payload) + if (!response) { + setError("Aucune authentification") + } else if (response.status == 201) { + setInfo(payload) setSuccess(t("additionalInfo.success")) } else { - setError(t("additionalInfo.error")) + setError((await response.json()).error) } } @@ -114,10 +152,7 @@ export function AdditionalInfoForm() { )} </Field> - <Field - name="dietary_restrictions" - validate={[required(t("additionalInfo.required"))]} - > + <Field name="dietary_restrictions"> {(field, props) => ( <Select {...props} @@ -146,7 +181,6 @@ export function AdditionalInfoForm() { value: "other", }, ]} - required /> )} </Field> @@ -252,10 +286,7 @@ export function AdditionalInfoForm() { )} </Field> - <Field - name="tshirt_size" - validate={[required(t("additionalInfo.required"))]} - > + <Field name="tshirt_size"> {(field, props) => ( <Select {...props} @@ -288,7 +319,6 @@ export function AdditionalInfoForm() { value: "xxl", }, ]} - required /> )} </Field> @@ -309,22 +339,27 @@ export function AdditionalInfoForm() { )} </Field> - <Field - name="study_proof" - type="File" - validate={[required(t("additionalInfo.required"))]} - > + <Field name="study_proof" type="File"> {(field, props) => ( <FileInput {...props} - value={field.value} error={field.error} label={t("additionalInfo.studyProofLabel")} - accept="image/*,.pdf" - required + accept=".pdf" /> )} </Field> + {info() && ( + <DownloadPdf + base64={info().study_proof} + file_name={ + info().first_name + + "_" + + info().last_name + + "_study_proof.pdf" + } + /> + )} <Field name="photo" @@ -334,14 +369,20 @@ export function AdditionalInfoForm() { {(field, props) => ( <FileInput {...props} - value={field.value} error={field.error} label={t("additionalInfo.photoLabel")} - accept="image/*" + accept=".png" required /> )} </Field> + {info() && info().photo && ( + <img + width="256" + height="256" + src={"data:image/png;base64," + info().photo} + ></img> + )} <Field name="cv" @@ -351,7 +392,6 @@ export function AdditionalInfoForm() { {(field, props) => ( <FileInput {...props} - value={field.value} error={field.error} label={t("additionalInfo.cvLabel")} accept=".pdf" @@ -360,10 +400,16 @@ export function AdditionalInfoForm() { )} </Field> - <Field - name="comments" - validate={[required(t("additionalInfo.required"))]} - > + {info() && ( + <DownloadPdf + base64={info().cv as unknown as string} + file_name={ + info().first_name + "_" + info().last_name + "_cv.pdf" + } + /> + )} + + <Field name="comments"> {(field, props) => ( <TextInput {...props} diff --git a/client/src/components/forms/NewPasswordForm.tsx b/client/src/components/forms/NewPasswordForm.tsx index 7a8267ba125f50b3812830af523bd5361b003e53..8b34cfbb7893245ffe5835f2358098728a9928d8 100644 --- a/client/src/components/forms/NewPasswordForm.tsx +++ b/client/src/components/forms/NewPasswordForm.tsx @@ -20,7 +20,6 @@ export function NewPassword() { setSuccess("Votre mot de passe a été changé avec succès") } } - createEffect(async () => {}) return ( <Form onSubmit={onSubmit} class="flex flex-col gap-8"> <Field diff --git a/client/src/components/forms/ParticipantForm.tsx b/client/src/components/forms/ParticipantForm.tsx index 789f68a66f247a459654a4fbe95102c3174209d3..915333050fa2fea6bb51cf5302a674de2410bc99 100644 --- a/client/src/components/forms/ParticipantForm.tsx +++ b/client/src/components/forms/ParticipantForm.tsx @@ -1,4 +1,4 @@ -import { PlusCircle, Trash } from "phosphor-solid-js" +import { Info, PlusCircle, Trash } from "phosphor-solid-js" import { MinimalParticipant } from "../../binding/MinimalParticipant" import { createResource, For, createSignal } from "solid-js" import { createForm } from "@modular-forms/solid" @@ -11,6 +11,7 @@ import { submitMinimalParticipant, } from "../../request/routes" import { t } from "../../stores/locale" +import { A } from "@solidjs/router" interface ParticipantRowProps { participant: ParticipantPreview @@ -66,7 +67,7 @@ function ParticipantRow(props: ParticipantRowProps) { {localStorage.getItem("role") === "organizer" && ( <td class="p-2 text-center">{p.university}</td> )} - <td class="">{p.contain_cv ? "✔ï¸" : "âŒ"}</td> + <td class="p-2 text-center">{p.contain_cv ? "✔ï¸" : "âŒ"}</td> <td class="flex flex-row gap-4 p-2 text-center"> {localStorage.getItem("role") !== "volunteer" && ( <button @@ -77,6 +78,12 @@ function ParticipantRow(props: ParticipantRowProps) { <Trash class="h-8 w-8"></Trash> </button> )} + <A + class="rounded bg-blue-500 p-1 font-bold text-white hover:bg-red-700" + href={"/participant-info/" + p.id} + > + <Info class="h-8 w-8"></Info> + </A> </td> </tr> {isModalOpen() && ( @@ -143,12 +150,30 @@ function getGivableRole() { } export default function ParticipantForm() { - const [user, { refetch }] = createResource(fetchParticipants) const [_form, { Form, Field }] = createForm<MinimalParticipant>() const onSubmit = async (data: MinimalParticipant) => { await submitMinimalParticipant(data) refetch() } + + const [search, setSearch] = createSignal("") + const [competitionSearched, setCompetitionSearched] = createSignal("") + const [userFetched, { refetch }] = createResource(fetchParticipants) + + const user = () => { + if (userFetched() == undefined) { + return [] + } + return userFetched().filter((p: ParticipantPreview) => { + return ( + p.university.toLowerCase().includes(search().toLowerCase()) && + p.competition + .toLowerCase() + .includes(competitionSearched().toLowerCase()) + ) + }) + } + const download = () => { const csv = jsonToCsv(user()) const blob = new Blob([csv], { type: "text/csv" }) @@ -162,12 +187,26 @@ export default function ParticipantForm() { return ( <div class="flex flex-col gap-4"> - <button - onclick={download} - class="w-64 rounded bg-blue-500 p-2 text-white hover:bg-blue-700" - > - Télécharger CSV - </button> + <div class="flex flex-row gap-4"> + <button + onclick={download} + class="w-64 rounded bg-blue-500 p-2 text-white hover:bg-blue-700" + > + Télécharger CSV + </button> + <input + type="text" + class="w-64 rounded border-2 border-gray-300 p-2" + placeholder="Filtrer par université" + onInput={(e) => setSearch(e.target.value)} + /> + <input + type="text" + class="w-64 rounded border-2 border-gray-300 p-2" + placeholder="Filtrer par compétition" + onInput={(e) => setCompetitionSearched(e.target.value)} + /> + </div> <Form onSubmit={onSubmit}> <table class="min-w-full border border-gray-300 bg-white"> <thead> @@ -193,7 +232,7 @@ export default function ParticipantForm() { </th> )} <th class="border-b border-gray-300 p-2 text-center"> - Infos données + Infos </th> <th class="border-b border-gray-300 p-2 text-center"> Actions @@ -201,14 +240,17 @@ export default function ParticipantForm() { </tr> </thead> <tbody> - <For each={user()}> - {(participant: ParticipantPreview) => ( - <ParticipantRow - participant={participant} - refetch={refetch} - /> - )} - </For> + {user() && ( + <For each={user()}> + {(participant: ParticipantPreview) => ( + <ParticipantRow + participant={participant} + refetch={refetch} + /> + )} + </For> + )} + {localStorage.getItem("role") == "organizer" && ( <tr class="border-b border-gray-200"> <td class="p-2"> diff --git a/client/src/i18n/en.ts b/client/src/i18n/en.ts index 92c90f74e9a2c296385f6ce5a5d7d08996ca8559..d9ffba28ddbcd78064a5af1e8ac7236e6e2ed821 100644 --- a/client/src/i18n/en.ts +++ b/client/src/i18n/en.ts @@ -121,7 +121,7 @@ const additionalInfo = { "Do you have a monthly OPUS card for the Montréal region at the moment of the CQI?", reducedMobilityLabel: "Do you need reduced mobility assistance? If so, please specify below.", - studyProofLabel: "Study proof with number of credits (pdf/jpg/jpeg/png)", + studyProofLabel: "Study proof with number of credits (pdf)", photoLabel: "Photo 512x512 (png)", cvLabel: "CV (pdf)", success: "Your information has been saved.", diff --git a/client/src/i18n/fr.ts b/client/src/i18n/fr.ts index c110fb1ccb75ffc0edbe9907faf6632eab767dbb..9ea801cf273359ff63f281d66d8175b031a2044f 100644 --- a/client/src/i18n/fr.ts +++ b/client/src/i18n/fr.ts @@ -121,8 +121,7 @@ const additionalInfo = { "Avez-vous, au moment de la CQI, une passe OPUS mensuelle pour la région de Montréal?", reducedMobilityLabel: "Avez-vous besoin d'aménagement pour mobilité réduite? Si oui, veuillez spécifier ci-dessous.", - studyProofLabel: - "Preuve d'études avec nombre de crédits (pdf/jpg/jpeg/png)", + studyProofLabel: "Preuve d'études avec nombre de crédits (pdf)", photoLabel: "Photo 512x512: (png)", cvLabel: "CV (pdf)", success: "Informations ajoutées avec succès", diff --git a/client/src/index.tsx b/client/src/index.tsx index 1d662c9f3979cc58eb72419f42c14f81e210c216..1d4f44c3476cb528518cf3ccfd95d20cbef7daa0 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -28,6 +28,7 @@ const AdditionalForm = lazy(() => import("./routes/AdditionalForm")) const ListParticipant = lazy(() => import("./routes/ListParticipant")) const ChangePassword = lazy(() => import("./routes/ChangePassword")) const ForgottenPassword = lazy(() => import("./routes/ForgottenPassword")) +const ParticipantInfo = lazy(() => import("./routes/ParticipantInfo")) const app = document.getElementById("app") if (app) { @@ -52,6 +53,10 @@ if (app) { path="/forgotten-password" component={ForgottenPassword} /> + <Route + path="/participant-info/:id" + component={ParticipantInfo} + /> <Route path="*" component={NotFound} /> </Router> ), diff --git a/client/src/request/routes.ts b/client/src/request/routes.ts index e5653ace9fba689080303fa07f28ffbe6d107efc..0913dd9c0b8109d5fec571d02e07147e11bdd093 100644 --- a/client/src/request/routes.ts +++ b/client/src/request/routes.ts @@ -13,7 +13,7 @@ import { fetch_put, } from "./fetch_wrapper" -export async function fetchParticipants() { +export async function fetchParticipants(): Promise<ParticipantPreview[]> { const request = await fetch_get("/participants") if (!request) { return [] @@ -61,51 +61,8 @@ export async function resetEmail(email: EmailResetPayload) { return await fetch_post_no_token("/password", email) } -function getBase64(file: File): Promise<string> { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - // Event listener when the file reading is completed - reader.onload = () => { - // `reader.result` contains the base64 string - const base64String = reader.result as string - const base64StringSplit = base64String.split(",")[1] - if (!base64StringSplit) { - reject(new Error("Failed to convert file to base64")) - } - resolve(base64StringSplit) // Removing the base64 prefix - } - - // Event listener for any error - reader.onerror = () => { - reject(new Error("Failed to convert file to base64")) - } - - // Reading the file as a data URL (which contains base64 encoded string) - reader.readAsDataURL(file) - }) -} - export async function patchParticipantInfo(info: ParticipantInfo) { - const payload: any = info - if (info.study_proof) { - payload.study_proof = await getBase64(info.study_proof) - } else { - payload.study_proof = "" - } - if (info.photo) { - payload.photo = await getBase64(info.photo) - } else { - payload.photo = "" - } - if (info.cv) { - payload.cv = await getBase64(info.cv) - } else { - payload.cv = "" - } - console.log(info) - console.log(payload) - return await fetch_patch("/participant", payload) + return await fetch_patch("/participant", info) } export async function getParticipant(id: string) { diff --git a/client/src/routes/ParticipantInfo.tsx b/client/src/routes/ParticipantInfo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35c06f235e655f4e10ac170ca4352de0db6bcd16 --- /dev/null +++ b/client/src/routes/ParticipantInfo.tsx @@ -0,0 +1,31 @@ +import { CaretCircleLeft } from "phosphor-solid-js" +import FixedImage from "../components/FixedImage" +import ParticipantAdditionnalInfo from "../components/ParticipantAdditionnalInfo" +import PrefetchLink from "../components/PrefetchLink" +import { ProtectedRoute } from "../components/ProtectedRoute" +import { t } from "../stores/locale" + +export default function ParticipantInfo() { + return ( + <ProtectedRoute> + <div class="flex w-full flex-col items-center justify-center font-futur"> + <FixedImage url="/banners/documents.svg" height="32rem"> + <h1 class="text-center font-futur text-6xl text-white"> + {"Tableau de bord"} + </h1> + </FixedImage> + <div class="relative -mt-32 flex w-full flex-col items-center"> + <PrefetchLink + to="/list-participant" + file="Dashboard" + class="absolute left-8 top-0 flex flex-row items-center gap-2" + > + <CaretCircleLeft size="2rem" /> + <span>{t("dashboard.goback")}</span> + </PrefetchLink> + <ParticipantAdditionnalInfo /> + </div> + </div> + </ProtectedRoute> + ) +}