Loading frontend/src/components/ExerciseCard/index.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ export const ExerciseCard: FC<ExerciseCardProps> = ({ const { name, definition, config, states } = exercise const allowedStartInterval = exerciseAllowedStartInterval(exercise) const exactFormatSeconds = useExactSecondsFormatter({}) return ( <div className={exerciseClass}> <h3>{name}</h3> Loading frontend/src/components/ExerciseList/ExerciseButtons/ManagingExerciseButtons.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -8,9 +8,9 @@ import type { NavigateOptions } from '@tanstack/react-router' import { useState, type FC } from 'react' import ConfirmAlert from '../../../exercisepanel/ConfirmAlert' import { DownloadLogsButton } from './DownloadLogsButton' import { start, useLoadingState } from './mutations' import { OnDemandExerciseButtons } from './OnDemandExerciseButtons' import { SynchronousExerciseButtons } from './SynchronousExerciseButtons' import { start, useLoadingState } from './mutations' interface ManagingExerciseButtonsProps { exercise: ExerciseSimple Loading frontend/src/users/ExerciseAssignment/Teams.tsx +3 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import AssignByTags from './AssignByTags' import AssignEqually from './AssignEqually' import { bodySection } from './classes' import TeamComponent from './Team' import { DownloadTraineesButton } from './TraineesButton/DownloadTraineesButton' import UnassignAllExercise from './UnassignAllExercise' const body = css` Loading Loading @@ -36,6 +37,8 @@ const Teams: FC<TeamsProps> = ({ teams, exerciseId }) => { <AssignEqually exerciseId={exerciseId} teamCount={teams.length} /> <Divider /> <AssignByTags exerciseId={exerciseId} /> <Divider /> <DownloadTraineesButton exerciseId={exerciseId} /> </ButtonGroup> } > Loading frontend/src/users/ExerciseAssignment/TraineesButton/DownloadTraineesButton.tsx 0 → 100644 +76 −0 Original line number Diff line number Diff line import type { ButtonProps } from '@blueprintjs/core' import { Button } from '@blueprintjs/core' import type { Team } from '@inject/graphql' import { GetExercise, useClient } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { pdf } from '@react-pdf/renderer' import { useState, type FC } from 'react' import { AssigneesPDF } from './TraineesPDF' interface DownloadTraineesButtonProps { exerciseId: string buttonProps?: ButtonProps } //TODO: Add export also in .csv format, add option to choose between formats export const DownloadTraineesButton: FC<DownloadTraineesButtonProps> = ({ exerciseId, }) => { const [isLoading, setIsLoading] = useState(false) const { t } = useTranslationFrontend() const client = useClient() const handleExportPDF = async () => { setIsLoading(true) try { const { data } = await client.query( GetExercise, { exerciseId }, { requestPolicy: 'network-only' } ) const teamsData = (data?.exerciseId?.teams ?? []) as Team[] const exerciseName = data?.exerciseId?.name as string const teamUserTriplets = teamsData .flatMap((team: Team) => (team.users ?? []).map(user => ({ teamName: team.name ?? '', firstName: user.firstName ?? '', lastName: user.lastName ?? '', })) ) .sort((a, b) => { const lastNameCompare = a.lastName.localeCompare(b.lastName) if (lastNameCompare !== 0) { return lastNameCompare } return a.firstName.localeCompare(b.firstName) }) const blob = await pdf( <AssigneesPDF triplets={teamUserTriplets} exerciseName={exerciseName} /> ).toBlob() const url = URL.createObjectURL(blob) const link = document.createElement('a') link.setAttribute('href', url) link.setAttribute('download', `${exerciseName}-assignees.pdf`) document.body.appendChild(link) link.click() document.body.removeChild(link) } finally { setIsLoading(false) } } return ( <Button icon='download' loading={isLoading} onClick={handleExportPDF} alignText='left' fill minimal text={t('sidebar.downloadAssignees')} /> ) } frontend/src/users/ExerciseAssignment/TraineesButton/TraineesPDF.tsx 0 → 100644 +170 −0 Original line number Diff line number Diff line import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import { type FC } from 'react' const styles = StyleSheet.create({ page: { padding: 24, fontSize: 10, fontFamily: 'Courier', }, header: { textAlign: 'center', fontSize: 14, marginBottom: 12, }, pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12, }, date: { fontSize: 10, }, columns: { flexDirection: 'row', gap: 12, }, column: { width: '50%', }, table: { borderWidth: 1, borderColor: '#000', }, row: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#000', }, cell: { padding: 4, borderRightWidth: 1, borderRightColor: '#000', }, teamCell: { width: '30%', }, lastNameCell: { width: '35%', }, firstNameCell: { width: '35%', borderRightWidth: 0, }, headerText: { fontWeight: 'bold', }, empty: { textAlign: 'center', marginTop: 24, }, }) export interface TeamUserTriplet { teamName: string firstName: string lastName: string } interface AssigneesPDFProps { triplets: TeamUserTriplet[] exerciseName: string } const chunk = <T,>(items: T[], size: number) => { const result: T[][] = [] for (let i = 0; i < items.length; i += size) { result.push(items.slice(i, i + size)) } return result } const TableHeader = () => ( <View style={styles.row}> <Text style={[styles.cell, styles.teamCell, styles.headerText]}>team</Text> <Text style={[styles.cell, styles.lastNameCell, styles.headerText]}> last_name </Text> <Text style={[styles.cell, styles.firstNameCell, styles.headerText]}> first_name </Text> </View> ) const TableBody: FC<{ triplets: TeamUserTriplet[] }> = ({ triplets }) => ( <> {triplets.map((triplet, index) => ( <View key={`${triplet.teamName}-${index}`} style={styles.row}> <Text style={[styles.cell, styles.teamCell]}>{triplet.teamName}</Text> <Text style={[styles.cell, styles.lastNameCell]}> {triplet.lastName} </Text> <Text style={[styles.cell, styles.firstNameCell]}> {triplet.firstName} </Text> </View> ))} </> ) export const AssigneesPDF: FC<AssigneesPDFProps> = ({ triplets, exerciseName, }) => { if (triplets.length === 0) { return ( <Document> <Page size='A4' orientation='portrait' style={styles.page}> <View style={styles.pageHeader}> <Text /> <Text style={styles.header}>{exerciseName}</Text> <Text /> </View> <Text style={styles.empty}>There are no trainees.</Text> </Page> </Document> ) } const pages = chunk(triplets, 50) return ( <Document> {pages.map((pageTriplets, pageIndex) => { const left = pageTriplets.slice(0, 25) const right = pageTriplets.slice(25, 50) return ( <Page key={`page-${pageIndex}`} size='A4' orientation='portrait' style={styles.page} > <View style={styles.pageHeader}> <Text /> <Text style={styles.header}>{exerciseName}</Text> <Text /> </View> <View style={styles.columns}> <View style={styles.column}> <View style={styles.table}> <TableHeader /> <TableBody triplets={left} /> </View> </View> {right.length > 1 ? ( <View style={styles.column}> <View style={styles.table}> <TableHeader /> <TableBody triplets={right} /> </View> </View> ) : undefined} </View> </Page> ) })} </Document> ) } Loading
frontend/src/components/ExerciseCard/index.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ export const ExerciseCard: FC<ExerciseCardProps> = ({ const { name, definition, config, states } = exercise const allowedStartInterval = exerciseAllowedStartInterval(exercise) const exactFormatSeconds = useExactSecondsFormatter({}) return ( <div className={exerciseClass}> <h3>{name}</h3> Loading
frontend/src/components/ExerciseList/ExerciseButtons/ManagingExerciseButtons.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -8,9 +8,9 @@ import type { NavigateOptions } from '@tanstack/react-router' import { useState, type FC } from 'react' import ConfirmAlert from '../../../exercisepanel/ConfirmAlert' import { DownloadLogsButton } from './DownloadLogsButton' import { start, useLoadingState } from './mutations' import { OnDemandExerciseButtons } from './OnDemandExerciseButtons' import { SynchronousExerciseButtons } from './SynchronousExerciseButtons' import { start, useLoadingState } from './mutations' interface ManagingExerciseButtonsProps { exercise: ExerciseSimple Loading
frontend/src/users/ExerciseAssignment/Teams.tsx +3 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import AssignByTags from './AssignByTags' import AssignEqually from './AssignEqually' import { bodySection } from './classes' import TeamComponent from './Team' import { DownloadTraineesButton } from './TraineesButton/DownloadTraineesButton' import UnassignAllExercise from './UnassignAllExercise' const body = css` Loading Loading @@ -36,6 +37,8 @@ const Teams: FC<TeamsProps> = ({ teams, exerciseId }) => { <AssignEqually exerciseId={exerciseId} teamCount={teams.length} /> <Divider /> <AssignByTags exerciseId={exerciseId} /> <Divider /> <DownloadTraineesButton exerciseId={exerciseId} /> </ButtonGroup> } > Loading
frontend/src/users/ExerciseAssignment/TraineesButton/DownloadTraineesButton.tsx 0 → 100644 +76 −0 Original line number Diff line number Diff line import type { ButtonProps } from '@blueprintjs/core' import { Button } from '@blueprintjs/core' import type { Team } from '@inject/graphql' import { GetExercise, useClient } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { pdf } from '@react-pdf/renderer' import { useState, type FC } from 'react' import { AssigneesPDF } from './TraineesPDF' interface DownloadTraineesButtonProps { exerciseId: string buttonProps?: ButtonProps } //TODO: Add export also in .csv format, add option to choose between formats export const DownloadTraineesButton: FC<DownloadTraineesButtonProps> = ({ exerciseId, }) => { const [isLoading, setIsLoading] = useState(false) const { t } = useTranslationFrontend() const client = useClient() const handleExportPDF = async () => { setIsLoading(true) try { const { data } = await client.query( GetExercise, { exerciseId }, { requestPolicy: 'network-only' } ) const teamsData = (data?.exerciseId?.teams ?? []) as Team[] const exerciseName = data?.exerciseId?.name as string const teamUserTriplets = teamsData .flatMap((team: Team) => (team.users ?? []).map(user => ({ teamName: team.name ?? '', firstName: user.firstName ?? '', lastName: user.lastName ?? '', })) ) .sort((a, b) => { const lastNameCompare = a.lastName.localeCompare(b.lastName) if (lastNameCompare !== 0) { return lastNameCompare } return a.firstName.localeCompare(b.firstName) }) const blob = await pdf( <AssigneesPDF triplets={teamUserTriplets} exerciseName={exerciseName} /> ).toBlob() const url = URL.createObjectURL(blob) const link = document.createElement('a') link.setAttribute('href', url) link.setAttribute('download', `${exerciseName}-assignees.pdf`) document.body.appendChild(link) link.click() document.body.removeChild(link) } finally { setIsLoading(false) } } return ( <Button icon='download' loading={isLoading} onClick={handleExportPDF} alignText='left' fill minimal text={t('sidebar.downloadAssignees')} /> ) }
frontend/src/users/ExerciseAssignment/TraineesButton/TraineesPDF.tsx 0 → 100644 +170 −0 Original line number Diff line number Diff line import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import { type FC } from 'react' const styles = StyleSheet.create({ page: { padding: 24, fontSize: 10, fontFamily: 'Courier', }, header: { textAlign: 'center', fontSize: 14, marginBottom: 12, }, pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12, }, date: { fontSize: 10, }, columns: { flexDirection: 'row', gap: 12, }, column: { width: '50%', }, table: { borderWidth: 1, borderColor: '#000', }, row: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#000', }, cell: { padding: 4, borderRightWidth: 1, borderRightColor: '#000', }, teamCell: { width: '30%', }, lastNameCell: { width: '35%', }, firstNameCell: { width: '35%', borderRightWidth: 0, }, headerText: { fontWeight: 'bold', }, empty: { textAlign: 'center', marginTop: 24, }, }) export interface TeamUserTriplet { teamName: string firstName: string lastName: string } interface AssigneesPDFProps { triplets: TeamUserTriplet[] exerciseName: string } const chunk = <T,>(items: T[], size: number) => { const result: T[][] = [] for (let i = 0; i < items.length; i += size) { result.push(items.slice(i, i + size)) } return result } const TableHeader = () => ( <View style={styles.row}> <Text style={[styles.cell, styles.teamCell, styles.headerText]}>team</Text> <Text style={[styles.cell, styles.lastNameCell, styles.headerText]}> last_name </Text> <Text style={[styles.cell, styles.firstNameCell, styles.headerText]}> first_name </Text> </View> ) const TableBody: FC<{ triplets: TeamUserTriplet[] }> = ({ triplets }) => ( <> {triplets.map((triplet, index) => ( <View key={`${triplet.teamName}-${index}`} style={styles.row}> <Text style={[styles.cell, styles.teamCell]}>{triplet.teamName}</Text> <Text style={[styles.cell, styles.lastNameCell]}> {triplet.lastName} </Text> <Text style={[styles.cell, styles.firstNameCell]}> {triplet.firstName} </Text> </View> ))} </> ) export const AssigneesPDF: FC<AssigneesPDFProps> = ({ triplets, exerciseName, }) => { if (triplets.length === 0) { return ( <Document> <Page size='A4' orientation='portrait' style={styles.page}> <View style={styles.pageHeader}> <Text /> <Text style={styles.header}>{exerciseName}</Text> <Text /> </View> <Text style={styles.empty}>There are no trainees.</Text> </Page> </Document> ) } const pages = chunk(triplets, 50) return ( <Document> {pages.map((pageTriplets, pageIndex) => { const left = pageTriplets.slice(0, 25) const right = pageTriplets.slice(25, 50) return ( <Page key={`page-${pageIndex}`} size='A4' orientation='portrait' style={styles.page} > <View style={styles.pageHeader}> <Text /> <Text style={styles.header}>{exerciseName}</Text> <Text /> </View> <View style={styles.columns}> <View style={styles.column}> <View style={styles.table}> <TableHeader /> <TableBody triplets={left} /> </View> </View> {right.length > 1 ? ( <View style={styles.column}> <View style={styles.table}> <TableHeader /> <TableBody triplets={right} /> </View> </View> ) : undefined} </View> </Page> ) })} </Document> ) }