diff --git a/public/locales/fr/ues.json.ts b/public/locales/fr/ues.json.ts index 7c172c5..0dd040d 100644 --- a/public/locales/fr/ues.json.ts +++ b/public/locales/fr/ues.json.ts @@ -2,7 +2,7 @@ // For more information, check the common.json.ts file export default { - 'browser': 'Guide des UEs', + browser: 'Guide des UEs', 'filter.search': 'Recherche dans le guide des UEs', 'filter.search.title': 'Recherche dans le guide des UEs', 'filter.creditType.title': 'Type de crédits', @@ -11,31 +11,51 @@ export default { 'filter.semester.title': 'Semestre', 'filter.semester.autumn': 'Automne', 'filter.semester.spring': 'Printemps', + 'overview.credits': 'Crédits', + 'overview.minors': 'Mineures', + 'overview.taughtIn': 'Enseigné en', + 'overview.requirements': 'prérequis', + 'detailed.siepLink': 'Voir sur le SIEP', 'detailed.inscriptionCode': "Code d'inscription", - 'detailed.workTime': 'Temps de travail', - 'detailed.workTime.project': 'Projet', - 'detailed.semester': "Prochain semestre d'ouverture", + 'detailed.inscriptionCode.copy': 'Copier dans le presse-papier', + 'detailed.inscriptionCode.empty': "Le code d'inscription n'a pas encore été publié", + 'detailed.worktime.hour': 'h', + 'detailed.worktime.cm': 'CM', + 'detailed.worktime.cm.tooltip': 'Cours Magistral', + 'detailed.worktime.td': 'TD', + 'detailed.worktime.td.tooltip': 'Travaux dirigés', + 'detailed.worktime.tp': 'TP', + 'detailed.worktime.tp.tooltip': 'Travaux pratiques', + 'detailed.worktime.the': 'THE', + 'detailed.worktime.the.tooltip': 'Travail hors encadrement', + 'detailed.worktime.project': 'Projet', + 'detailed.worktime.internship': 'stage', + 'detailed.semester': 'Ouvert en', 'detailed.semester.none': "Pas de semestre d'ouverture prévu", 'detailed.description': 'Description', 'detailed.program': 'Programme', 'detailed.objectives': 'Objectifs', - 'detailed.taughtIn': "Langue d'enseignement", + 'detailed.taughtIn': 'Langue', 'detailed.minors': 'Mineurs', + 'detailed.minors.none': 'Cette UE ne compte dans aucun mineur', 'detailed.credits': 'Crédits', 'detailed.noWorkingTimeInfo': 'Aucune information sur le temps de travail', - 'detailed.branchOptions': 'Niveau', + 'detailed.dfpdata': 'Inscription', + 'detailed.branchOptions': 'Profil', 'detailed.requirements': 'Pré-requis', 'detailed.requirements.none': 'Aucun pré-requis', + 'detailed.rates.title': 'Avis des étudiants', + 'detailed.rates.error': 'Une erreur est survenue lors du chargement des avis', + 'detailed.rates.delete': 'Supprimer mon avis', 'detailed.comments.loginRequired': 'Connexion requise pour voir les commentaires', 'detailed.comments.author.anonymous': 'Anonyme', 'detailed.comments.author.deleted': 'Utilisateur supprimé', 'detailed.comments.writtenDate': 'Écrit le {{date}}', 'detailed.comments.conversation.see': 'Voir la conversation ({{responseCount}} réponses)', 'detailed.comments.conversation.see.empty': 'Répondre', - 'detailed.comments.resume': - "Commentaire de {{authorFirstName}} {{authorLastName}} sur l'UE {{ue}} au semestre {{semester}} ({{date}})", + 'detailed.comments.resume': "Commentaire sur l'UE {{ue}}", + 'detailed.comments.semester': 'Semestre {{semester}}', 'detailed.comments.updatedAt': 'Mis à jour le {{date}}', - 'detailed.comments.resume.anonymous': "Commentaire anonyme sur l'UE {{ue}} au semestre {{semester}} ({{date}})", 'detailed.comments.answers.answerTitle': 'Répondre dans ce fil de discussion', 'detailed.comments.answers.answerButton': 'Envoyer', 'detailed.comments.answers.answerEntry': 'Tapez votre réponse ici', diff --git a/src/api/api.ts b/src/api/api.ts index e1f6f7c..eab3be6 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8,6 +8,7 @@ import { StatusCodes } from 'http-status-codes'; export enum ResponseError { 'not_json', 'timeout', + 'aborted', 'unknown', } @@ -65,8 +66,10 @@ export class ResponseHandler { : () => R | void; }> = {}; private readonly promise: Promise; + public readonly abortController: AbortController; - constructor(rawResponse: Promise>) { + constructor(rawResponse: Promise>, abortController: AbortController) { + this.abortController = abortController; this.promise = rawResponse.then((response) => { if ('error' in response) { return this.handlers[response.error] @@ -103,7 +106,7 @@ export class ResponseHandler { * @param rawResponse The raw response from the API. */ function formatResponse(rawResponse: RawResponseType): T { - if (typeof rawResponse === 'string' && !isNaN(Date.parse(rawResponse))) { + if (typeof rawResponse === 'string' && rawResponse.search(/^\d{4}(?:-\d{2}){2}T(?:\d{2}:){2}\d{2}\.\d{3}Z$/) === 0) { return new Date(rawResponse) as T; } else if (Array.isArray(rawResponse)) { return rawResponse.map(formatResponse) as T; @@ -132,6 +135,7 @@ async function internalRequestAPI( timeoutMillis: number, version: string, isFile: true, + abortController: AbortController, ): Promise>; async function internalRequestAPI( method: string, @@ -140,6 +144,7 @@ async function internalRequestAPI( timeoutMillis: number, version: string, isFile: boolean, + abortController: AbortController, ): Promise>; async function internalRequestAPI( method: string, @@ -148,6 +153,7 @@ async function internalRequestAPI( timeoutMillis: number, version: string, isFile: boolean, + abortController: AbortController, ): Promise> { // Generate headers const headers = new Headers(); @@ -155,7 +161,6 @@ async function internalRequestAPI( if (!isFile) headers.append('Content-Type', 'application/json'); // Add timeout to the request - const abortController = new AbortController(); const timeout = setTimeout(() => { abortController.abort(); }, timeoutMillis); @@ -194,6 +199,7 @@ async function internalRequestAPI( } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { + if (error === 'query-update') return { error: ResponseError.aborted }; if (error instanceof Error && error.name === 'AbortError') { console.error('Request timed out'); return { error: ResponseError.timeout }; @@ -241,7 +247,11 @@ function requestAPI( isFile = false, }: { timeoutMillis?: number; version?: string; isFile?: boolean } = {}, ): ResponseHandler { - return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile)); + const abortController = new AbortController(); + return new ResponseHandler( + internalRequestAPI(method, route, body, timeoutMillis, version, isFile, abortController), + abortController, + ); } // Set the authorization header with the given token for next requests diff --git a/src/api/pagination.hook.ts b/src/api/pagination.hook.ts index f311ce0..9f8936f 100644 --- a/src/api/pagination.hook.ts +++ b/src/api/pagination.hook.ts @@ -7,6 +7,7 @@ type PaginationHook = { total: number; updateFilters: (query: Record) => void; fetchNextItems: () => void; + invalidateItems: () => void; }; /** @@ -15,6 +16,7 @@ type PaginationHook = { export function usePaginationLoader(path: string): PaginationHook { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); + const [abortController, setAbortController] = useState(null); const searching = useRef(false); const lastSearch = useRef>({}); const itemsPerPage = useRef(0); @@ -22,44 +24,55 @@ export function usePaginationLoader(path: string): PaginationHook { const api = useAPI(); - const updateItems = (query: Record) => { - if (searching.current) return; - else searching.current = true; - + const invalidateItems = () => { + searching.current = true; setItems([...Array(itemsPerPage.current || 20).fill(null)]); + }; + + const updateItems = (query: Record) => { const { page, ...queryData } = query; - api - .get>(`${path}?${new URLSearchParams(query)}`) - .on('success', (body) => { - setTotal(body.itemCount); - setItems(body.items); - itemsPerPage.current = body.itemsPerPage; - lastSearch.current = queryData; - searching.current = false; - pageIndex.current = (page && Number(page)) || 1; - }) - .on('error', () => (searching.current = false)) - .on('failure', () => (searching.current = false)); + if (abortController?.signal?.aborted === false) abortController.abort('query-update'); + setAbortController( + api + .get>(`${path}?${new URLSearchParams(query)}`) + .on('success', (body) => { + setTotal(body.itemCount); + setItems(body.items); + itemsPerPage.current = body.itemsPerPage; + lastSearch.current = queryData; + searching.current = false; + pageIndex.current = (page && Number(page)) || 1; + }) + .on('error', () => (searching.current = false)) + .on('failure', () => (searching.current = false)).abortController, + ); }; const fetchNextPage = () => { - if (searching.current || items.length >= total) return; - else searching.current = true; - + if (items.length >= total) return; + searching.current = true; const pendingItemCount = Math.min(itemsPerPage.current, total - items.length); setItems((prev) => [...prev, ...Array(pendingItemCount).fill(null)]); - api - .get>( - `${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`, - ) - .on('success', (body) => { - pageIndex.current++; - setTotal(body.itemCount); - setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]); - searching.current = false; - }) - .on('error', () => (searching.current = false)) - .on('failure', () => (searching.current = false)); + setAbortController( + api + .get>( + `${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`, + ) + .on('success', (body) => { + pageIndex.current++; + setTotal(body.itemCount); + setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]); + searching.current = false; + }) + .on('error', () => (searching.current = false)) + .on('failure', () => (searching.current = false)).abortController, + ); + }; + return { + items, + total, + updateFilters: updateItems, + fetchNextItems: fetchNextPage, + invalidateItems, }; - return { items, total, updateFilters: updateItems, fetchNextItems: fetchNextPage }; } diff --git a/src/api/ue/ue.interface.ts b/src/api/ue/ue.interface.ts index 9e487ec..0010275 100644 --- a/src/api/ue/ue.interface.ts +++ b/src/api/ue/ue.interface.ts @@ -1,15 +1,10 @@ export interface UE { code: string; - inscriptionCode: string; name: string; info: { - requirements: Array<{ code: string }>; - comment: string; - degree: string; - languages: string; - minors: string; - objectives: string; - program: string; + requirements: Array; + languages: Array; + minors: Array; }; credits: Array<{ credits: number; @@ -17,14 +12,14 @@ export interface UE { code: string; name: string; }; - }>; - branchOption: Array<{ - code: string; - name: string; - branch: { + branchOption: Array<{ code: string; name: string; - }; + branch: { + code: string; + name: string; + }; + }>; }>; openSemester: Array<{ code: string; @@ -33,16 +28,51 @@ export interface UE { }>; } -export interface DetailedUE extends UE { - validationRate: number; - workTime: { - cm: number; - td: number; - tp: number; - the: number; - project: number; - internship: number; - }; +export interface DetailedUE { + code: string; + creationYear: number; + updateYear: number; + ueofs: Array<{ + name: string; + code: string; + siepId: string; + inscriptionCode: string; + credits: Array<{ + credits: number; + category: { + code: string; + name: string; + }; + branchOptions: Array<{ + code: string; + name: string; + branch: { + code: string; + name: string; + }; + }>; + }>; + info: { + objectives: string; + program: string; + language: string; + minors: Array; + requirements: Array; + }; + openSemester: Array<{ + code: string; + start: Date; + end: Date; + }>; + workTime: { + cm: number; + td: number; + tp: number; + the: number; + project: boolean; + internship: number; + }; + }>; starVotes: { [criterionId: string]: number; }; diff --git a/src/app/ues/[code]/comments/[commentId]/page.tsx b/src/app/ues/[code]/comments/[commentId]/page.tsx index aafdd31..548b2e3 100644 --- a/src/app/ues/[code]/comments/[commentId]/page.tsx +++ b/src/app/ues/[code]/comments/[commentId]/page.tsx @@ -14,6 +14,11 @@ import { editCommentReply } from '@/api/commentReply/editCommentReply'; import { useAPI } from '@/api/api'; import { sendCommentReply } from '@/api/commentReply/sendCommentReply'; import { usePageSettings } from '@/module/pageSettings'; +import Enter from '@/icons/Enter'; +import User from '@/icons/User'; +import Comment from '@/icons/Comment'; +import Clock from '@/icons/Clock'; +import Link from '@/components/UI/Link'; function CommentEditorFooter(originalComment: string, onUpdate: (text: string) => void, t: TFunction) { return function CommentEditorFooter({ text, disable }: { text: string; disable: () => void }) { @@ -46,55 +51,72 @@ export default function CommentDetailsPage() { return (

- {comment.isAnonymous - ? t('ues:detailed.comments.resume.anonymous', { - ue: ue.code, - semester: comment.semester.code, - date: comment.createdAt.toLocaleDateString(), - }) - : t('ues:detailed.comments.resume', { - authorFirstName: comment.author.firstName, - authorLastName: comment.author.lastName, - ue: ue.code, - semester: comment.semester.code, - date: comment.createdAt.toLocaleDateString(), - })} + {t('ues:detailed.comments.resume', { + ue: ue.code, + })}

- {comment.updatedAt && ( -

- {t('ues:detailed.comments.updatedAt', { date: comment.updatedAt.toLocaleDateString() })} -

- )} +
+ {!comment.isAnonymous && ( +
+ + + {comment.author.firstName} {comment.author.lastName} + +
+ )} +
+ + {t('ues:detailed.comments.semester', { semester: comment.semester.code })} +
+
+ +
+
{t('ues:detailed.comments.writtenDate', { date: comment.createdAt.toLocaleDateString() })}
+ {comment.updatedAt && ( +
{t('ues:detailed.comments.updatedAt', { date: comment.updatedAt.toLocaleDateString() })}
+ )} +
+
+

{comment.body}

{comment.answers.map((answer, i) => (
-

- {answer.author - ? `${answer.author.firstName} ${answer.author.lastName}` - : t('ues:detailed.comments.author.deleted')} -

-

- {t('ues:detailed.comments.writtenDate', { date: answer.createdAt.toLocaleDateString() })} -

- { - const newAnswer = await editCommentReply(api, answer.id, body).toPromise(); - if (!newAnswer) return false; - setComment({ - ...comment, - answers: [...comment.answers.slice(0, i), newAnswer, ...comment.answers.slice(i + 1)], - }); - return true; - }, - t, - )} - enabled={answer.author.id === user.id} - /> +
+ +
+
+

+ {answer.author ? ( + + {answer.author.firstName} {answer.author.lastName} + + ) : ( + t('ues:detailed.comments.author.deleted') + )} +

+

+ {t('ues:detailed.comments.writtenDate', { date: answer.createdAt.toLocaleDateString() })} +

+ { + const newAnswer = await editCommentReply(api, answer.id, body).toPromise(); + if (!newAnswer) return false; + setComment({ + ...comment, + answers: [...comment.answers.slice(0, i), newAnswer, ...comment.answers.slice(i + 1)], + }); + return true; + }, + t, + )} + enabled={answer.author.id === user.id} + /> +
))}
diff --git a/src/app/ues/[code]/comments/[commentId]/style.module.scss b/src/app/ues/[code]/comments/[commentId]/style.module.scss index 385db2c..71b5401 100644 --- a/src/app/ues/[code]/comments/[commentId]/style.module.scss +++ b/src/app/ues/[code]/comments/[commentId]/style.module.scss @@ -9,8 +9,19 @@ color: $ung-light-blue; } - & > .body { - margin-top: 1.5rem; + .meta { + margin: 1rem 0; + + svg { + width: 1em; + height: 1.1em; + vertical-align: bottom; + } + & > * { + display: flex; + flex-flow: row nowrap; + gap: 5px; + } } .updateDate { @@ -19,56 +30,61 @@ margin-top: 0.3rem; } - .thoughts { + .answerTitle { + margin-top: 3rem; + margin-bottom: 1rem; + color: $ung-dark-grey; + } + + p.body { + text-align: justify; + } + + .comments { + display: flex; + flex-flow: column nowrap; + gap: 1rem; + padding: 1rem 0; + .comment { - margin: 30px 0; + display: flex; + flex-flow: row nowrap; + gap: 1em; + padding: 0 1em; + + .sideIcon { + width: 12px; + height: 12px; + margin-top: 0.5em; + padding: 5px; + box-sizing: content-box; + border-radius: 50%; + border: 1px solid $navigation-item-tooltip-color; + + .answerIcon { + width: 12px; + height: 12px; + color: $ung-light-grey; + transform: scaleY(-1) translateY(25%); + } + } .author { + color: $ung-light-blue; font-weight: bold; - font-size: 1.2rem; - color: $ung-dark-grey; - margin-bottom: 0.2rem; } .date { - font-weight: bold; + font-style: italic; color: $ung-light-grey; } .body { - margin-left: 1rem; - margin-top: 0.5rem; - padding-left: 0.7rem; - position: relative; - - &:after { - content: ''; - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 3px; - background: linear-gradient(90deg, $ung-dark-grey, rgba($ung-dark-grey, 0.5)); - } - - .button { - right: 0; - } - } - - &:last-child { - margin-bottom: 0; - //background-color: red; + text-align: justify; } } } - .answerTitle { - margin-top: 3rem; - margin-bottom: 1rem; - color: $ung-dark-grey; - } - .input { width: 100%; flex-grow: 1; @@ -84,4 +100,4 @@ position: relative; } } -} \ No newline at end of file +} diff --git a/src/app/ues/[code]/page.tsx b/src/app/ues/[code]/page.tsx index 220bd47..40308b1 100644 --- a/src/app/ues/[code]/page.tsx +++ b/src/app/ues/[code]/page.tsx @@ -14,7 +14,7 @@ import doUERate from '@/api/ueRate/doUERate'; import deleteUERate from '@/api/ueRate/deleteUERate'; import StarRating from '@/components/StarRating'; import TextArea from '@/components/UI/TextArea'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import sendComment from '@/api/comment/sendComment'; import { useAPI } from '@/api/api'; import { usePageSettings } from '@/module/pageSettings'; @@ -22,13 +22,18 @@ import useAnnals from '@/api/annals/fetchAnnals'; import useAnnalMetadata from '@/api/annals/fetchMetadata'; import ExamList from '@/components/ues/ExamList'; import ExamSender from '@/components/ues/ExamSender'; +import Tooltip from '@/components/UI/Tooltip'; +import Link from '@/components/UI/Link'; +import { UserType } from '@/module/user'; export default function UEDetailsPage() { usePageSettings({}); const params = useParams<{ code: string }>(); const { t } = useAppTranslation(); const logged = useAppSelector((state) => state.session.logged); + const type = useAppSelector((state) => state.user?.type); const [ue, refreshUE] = useUE(params.code as string); + const [ueofIndex, setUeofIndex] = useState(0); const criteria = useUERateCriteria(); const [myRates, setMyRates] = useGetRate(params.code); const [writtingComment, setWrittingComment] = useState(''); @@ -37,9 +42,7 @@ export default function UEDetailsPage() { const [isAnnalUploaderOpen, setAnnalUploaderOpen] = useState(false); const api = useAPI(); - if (!ue || !criteria || (!myRates && logged)) { - return false; - } + if (!ue) return false; const onRate = async (criterionId: string, hasAlreadyRated: boolean, rate: number) => { const newRate = await doUERate(api, params.code, criterionId as string, rate).toPromise(); @@ -62,60 +65,181 @@ export default function UEDetailsPage() { refreshUE(); }; + const clipboardCopy = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const setUeof = (ueof: number | string) => { + if (typeof ueof === 'number') setUeofIndex(ueof); + }; + return (
-

{ue.code}

-

{ue.name}

+
+ {ue.ueofs.map((ueof, index) => ( +
setUeof(index)}> + {ueof.code} +
+ ))} +
+
+
+

{ue.code}

+

{ue.ueofs[ueofIndex].name}

+
+
+ {ue.ueofs[ueofIndex].workTime ? ( + <> + {ue.ueofs[ueofIndex].workTime.cm ? ( +
+
+ {ue.ueofs[ueofIndex].workTime.cm} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.cm')}
+
+
+ ) : ( + <> + )} + {ue.ueofs[ueofIndex].workTime.td ? ( +
+
+ {ue.ueofs[ueofIndex].workTime.td} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.td')}
+
+
+ ) : ( + <> + )} + {ue.ueofs[ueofIndex].workTime.tp ? ( +
+
+ {ue.ueofs[ueofIndex].workTime.tp} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.tp')}
+
+
+ ) : ( + <> + )} + {ue.ueofs[ueofIndex].workTime.the ? ( +
+
+ {ue.ueofs[ueofIndex].workTime.the} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.the')}
+
+
+ ) : ( + <> + )} + {ue.ueofs[ueofIndex].workTime.internship ? ( +
+
+ {ue.ueofs[ueofIndex].workTime.internship} + {t('ues:detailed.worktime.hour')} +
+
{t('ues:detailed.worktime.internship')}
+
+ ) : ( + <> + )} + {ue.ueofs[ueofIndex].workTime.project ? ( +
+
{Number(ue.ueofs[ueofIndex].workTime.project)}
+
{t('ues:detailed.worktime.project')}
+
+ ) : ( + <> + )} + + ) : ( + t('ues:detailed.noWorkingTimeInfo') + )} +
+
{!isAnnalUploaderOpen ? ( <>
-
-

Informations générales

-

- {t('ues:detailed.description')} : {ue.info.comment} -
- {t('ues:detailed.program')} : {ue.info.program} -
- {t('ues:detailed.objectives')} : {ue.info.objectives} -
- {t('ues:detailed.taughtIn')} : {ue.info.languages} -
- {t('ues:detailed.minors')} : {ue.info.minors} -
- {t('ues:detailed.credits')} :{' '} - {ue.credits.map((credits) => `${credits.credits}${credits.category.code}`).join(', ')} -

+
{t('ues:detailed.program')}
+
{ue.ueofs[ueofIndex].info.program}
+
{t('ues:detailed.objectives')}
+
{ue.ueofs[ueofIndex].info.objectives}
+
{t('ues:detailed.taughtIn')}
+
{ue.ueofs[ueofIndex].info.language}
+
{t('ues:detailed.minors')}
+
+ {ue.ueofs[ueofIndex].info.minors.length + ? ue.ueofs[ueofIndex].info.minors.join(', ') + : t('ues:detailed.minors.none')}
-
-

{t('ues:detailed.workTime')}

- {ue.workTime ? ( - <> -

CM : {ue.workTime.cm}

-

TD : {ue.workTime.td}

-

TP : {ue.workTime.tp}

-

- {t('ues:detailed.workTime.project')} : {ue.workTime.project} -

-

THE : {ue.workTime.the}

- - ) : ( - t('ues:detailed.noWorkingTimeInfo') - )} +
{t('ues:detailed.credits')}
+
+ {Array.from( + new Set(ue.ueofs[ueofIndex].credits.map((credits) => `${credits.credits} ${credits.category.code}`)), + ).join(', ')}
-
-

Information pour faire l'UE

- {t('ues:detailed.semester')} :{' '} - {ue.openSemester.find((semester) => new Date(semester.start).getTime() > Date.now())?.code ?? - t('ues:detailed.semester.none')}{' '} -
- {t('ues:detailed.inscriptionCode')} : {ue.inscriptionCode}
- {t('ues:detailed.branchOptions')} : {ue.branchOption.map((branchOption) => branchOption.code).toString()}{' '} -
- {t('ues:detailed.requirements')} :{' '} - {ue.info.requirements.length === 0 - ? t('ues:detailed.requirements.none') - : ue.info.requirements.toString()} +
+
+

{t('ues:detailed.dfpdata')}

+
+
{t('ues:detailed.semester')}
+
+ {ue.ueofs[ueofIndex].openSemester + .filter((semester) => new Date(semester.end).getTime() > Date.now()) + .map((semester) => semester.code) + .join(', ') || t('ues:detailed.semester.none')}{' '} +
+
{t('ues:detailed.siepLink')}
+
+ + {ue.ueofs[ueofIndex].code} + +
+
{t('ues:detailed.inscriptionCode')}
+
+ {ue.ueofs[ueofIndex].inscriptionCode ? ( + + clipboardCopy(ue.ueofs[ueofIndex].inscriptionCode)}> + {ue.ueofs[ueofIndex].inscriptionCode} + + + ) : ( + t('ues:detailed.inscriptionCode.empty') + )} +
+
{t('ues:detailed.branchOptions')}
+
+ {ue.ueofs[ueofIndex].credits + .flatMap((credit) => credit.branchOptions.map((branchOption) => branchOption.code)) + .join(', ')} +
+
{t('ues:detailed.requirements')}
+
+ {ue.ueofs[ueofIndex].info.requirements.length === 0 + ? t('ues:detailed.requirements.none') + : ue.ueofs[ueofIndex].info.requirements + .map((req) => ( + + {req} + + )) + .reduce((prev, curr) => (prev.length ? [...prev, ', ', curr] : [curr]), [] as ReactNode[])} +
-
-

Avis des étudiants

-
- {Object.entries(ue.starVotes).map(([id, value]) => { - const myRate = myRates?.find((rate) => rate.criterionId === id); - return ( -
-

{criteria.find((criterion): criterion is UERateCriterion => criterion.id === id)?.name}

- - {myRates && ( - <> - onRate(id as string, !!myRate, rate)} - /> - {myRate && ( - - )} - - )} + {logged && (type === UserType.STUDENT || type === UserType.FORMER_STUDENT) && ( +
+

{t('ues:detailed.rates.title')}

+
c).join(' ')}> + {criteria && ue.starVotes + ? Object.entries(ue.starVotes).map(([id, value]) => { + const myRate = myRates?.find((rate) => rate.criterionId === id); + return ( +
+

+ {criteria.find((criterion): criterion is UERateCriterion => criterion.id === id)?.name} +

+ + {myRates && ( + <> + onRate(id as string, !!myRate, rate)} + /> + {myRate && ( + + )} + + )} +
+ ); + }) + : t('ues:detailed.rates.error')} +
+ {logged && (type === UserType.STUDENT || type === UserType.FORMER_STUDENT) ? ( + <> +
+ {t('ues:detailed.comments.write')} +