diff --git a/packages/api/schema.gql b/packages/api/schema.gql index c0ff346a..738a33ef 100644 --- a/packages/api/schema.gql +++ b/packages/api/schema.gql @@ -380,6 +380,7 @@ type Report implements CommentableInterface { id: ID! status: ReportStatus! summary: String + traineeId: ID! week: Int! year: Int! reportAccepted: String diff --git a/packages/api/src/graphql.ts b/packages/api/src/graphql.ts index a38c4253..26dfc8a5 100644 --- a/packages/api/src/graphql.ts +++ b/packages/api/src/graphql.ts @@ -511,6 +511,7 @@ export type GqlReport = GqlCommentableInterface & { reportAccepted?: Maybe; status: GqlReportStatus; summary?: Maybe; + traineeId: Scalars['ID']['output']; week: Scalars['Int']['output']; year: Scalars['Int']['output']; }; @@ -979,6 +980,7 @@ export type GqlReportResolvers, ParentType, ContextType>; status?: Resolver; summary?: Resolver, ParentType, ContextType>; + traineeId?: Resolver; week?: Resolver; year?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/backend/seeds/users.json b/packages/backend/seeds/users.json index 338f7925..cc10e7f1 100644 --- a/packages/backend/seeds/users.json +++ b/packages/backend/seeds/users.json @@ -15,6 +15,38 @@ "type": "Trainee", "trainerId": "456" }, + { + "id": "1234", + "companyId": "expGermany", + "course": "Trainee Course", + "createdAt": "2019-08-01T11:25:18.000Z", + "email": "trainee@exampleCompany.com", + "firstName": "Trainee", + "lastName": "Traineeson", + "language": "de", + "startDate": "2022-08-01T22:00:00.000Z", + "endDate": "2027-07-31T22:00:00.000Z", + "theme": "light", + "token": "123", + "type": "Trainee", + "trainerId": "456" + }, + { + "id": "12345", + "companyId": "expGermany", + "course": "Trainee Course", + "createdAt": "2019-08-01T11:25:18.000Z", + "email": "trainee@exampleCompany.com", + "firstName": "Trainee", + "lastName": "Traineeson", + "language": "de", + "startDate": "2022-08-01T22:00:00.000Z", + "endDate": "2027-07-31T22:00:00.000Z", + "theme": "light", + "token": "123", + "type": "Trainee", + "trainerId": "456" + }, { "id": "456", "companyId": "expGermany", diff --git a/packages/backend/src/permissions.ts b/packages/backend/src/permissions.ts index f5b2ac3f..c15e4668 100644 --- a/packages/backend/src/permissions.ts +++ b/packages/backend/src/permissions.ts @@ -43,7 +43,7 @@ export const permissions = shield( alexaLinkingUrl: authenticated, // Trainee queries - reports: and(authenticated, trainee), + reports: and(authenticated, or(trainee, trainer)), suggestions: and(authenticated, trainee), reportForYearAndWeek: and(authenticated, trainee), print: and(authenticated, trainee), @@ -53,9 +53,9 @@ export const permissions = shield( // Trainer and Admin Queries trainees: and(authenticated, or(trainer, admin)), + getUser: and(authenticated, or(admin, trainer)), // Admin Queris - getUser: and(authenticated, admin), trainers: and(authenticated, admin), admins: and(authenticated, admin), }, diff --git a/packages/backend/src/resolvers/report.resolver.ts b/packages/backend/src/resolvers/report.resolver.ts index afca498e..4d89d5dd 100644 --- a/packages/backend/src/resolvers/report.resolver.ts +++ b/packages/backend/src/resolvers/report.resolver.ts @@ -14,12 +14,13 @@ import { AuthenticatedContext, GqlResolvers, Report, Trainee, TraineeContext } f import { isTrainee, isTrainer } from '../permissions' import { reportById, reportByYearAndWeek, saveReport, updateReport } from '../repositories/report.repo' -import { traineeByReportId } from '../repositories/trainee.repo' +import { traineeByReportId, traineesByTrainerId } from '../repositories/trainee.repo' import { sendNotificationMail } from '../services/email.service' import { isReportStatus, reportDate, reportsWithinApprenticeship, + reportsForTrainer, validateStatusUpdate, } from '../services/report.service' import { endOfToolUsage, generateReports, startOfToolUsage } from '../services/trainee.service' @@ -50,14 +51,26 @@ export const reportTraineeResolver: GqlResolvers = { return `/report/${getISOWeekYear(prevWeek)}/${getISOWeek(prevWeek)}` }, }, +} + +export const reportResolver: GqlResolvers = { Query: { reports: async (_parent, { statuses }, { currentUser }) => { // Generate reports if executed local - if (IS_OFFLINE) { + if (IS_OFFLINE && isTrainee(currentUser)) { await generateReports(currentUser) } - return reportsWithinApprenticeship(currentUser, statuses) + if (isTrainee(currentUser)) { + return reportsWithinApprenticeship(currentUser, statuses) + } + + if (isTrainer(currentUser)) { + const trainees = await traineesByTrainerId(currentUser.id) + return reportsForTrainer(trainees, statuses) + } + + return [] }, reportForYearAndWeek: async (_parent, { week, year }, { currentUser }) => { let report = await reportByYearAndWeek(year, week, currentUser.id) @@ -92,9 +105,6 @@ export const reportTraineeResolver: GqlResolvers = { return report }, }, -} - -export const reportResolver: GqlResolvers = { Mutation: { updateReport: async (_parent, { id, department, status: newStatus, summary }, { currentUser }) => { let report: Report | undefined diff --git a/packages/backend/src/services/report.service.ts b/packages/backend/src/services/report.service.ts index bf987131..726d61cb 100644 --- a/packages/backend/src/services/report.service.ts +++ b/packages/backend/src/services/report.service.ts @@ -47,6 +47,12 @@ export const reportsWithinApprenticeship = async ( ) } +export const reportsForTrainer = async (trainees: Trainee[], statuses?: GqlReportStatus[]): Promise => { + const allReports = await Promise.all(trainees.map((trainee) => reportsWithinApprenticeship(trainee, statuses))) + + return sortReportsByDate(allReports.flat()) +} + const DANGEROUSLY_reportsForTraineeAndStatuses = async ( traineeId: string, statuses: GqlReportStatus[] diff --git a/packages/components/src/trainee-row.ts b/packages/components/src/trainee-row.ts index 257588fd..55710f0e 100644 --- a/packages/components/src/trainee-row.ts +++ b/packages/components/src/trainee-row.ts @@ -16,6 +16,15 @@ export const StyledHeader = styled(NavLink)` flex-grow: 1; ` +export const StyledHeaderSpan = styled.span` + display: flex; + align-items: center; + text-decoration: none; + flex-grow: 1; + padding: 16px; + cursor: pointer; +` + export const StyledName = styled.span` font-size: ${FontSizes.copy}; font-weight: bold; diff --git a/packages/frontend/src/components/navigation.tsx b/packages/frontend/src/components/navigation.tsx index afa4c03f..736b3add 100644 --- a/packages/frontend/src/components/navigation.tsx +++ b/packages/frontend/src/components/navigation.tsx @@ -86,6 +86,9 @@ const Navigation: React.FC = () => { {strings.navigation.trainees} + + {strings.navigation.archive} + {strings.navigation.settings} diff --git a/packages/frontend/src/components/trainee-selector.tsx b/packages/frontend/src/components/trainee-selector.tsx new file mode 100644 index 00000000..187ff8bb --- /dev/null +++ b/packages/frontend/src/components/trainee-selector.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { StyledName, StyledWrapper, Flex, StyledHeaderSpan } from '@lara/components' + +import { Trainee } from '../graphql' +import Avatar from './avatar' + +interface TraineeSelectorProps { + trainee: Pick + onVariableChange: (traineeId: string) => void +} + +const TraineeSelector: React.FunctionComponent = (props) => { + const { trainee, onVariableChange } = props + + const handleClick = () => { + onVariableChange(trainee.id) + } + + return ( + + + + + + {trainee.firstName} {trainee.lastName} + + + + + ) +} + +export default TraineeSelector diff --git a/packages/frontend/src/graphql/index.tsx b/packages/frontend/src/graphql/index.tsx index 199ce7ee..794047dc 100644 --- a/packages/frontend/src/graphql/index.tsx +++ b/packages/frontend/src/graphql/index.tsx @@ -510,6 +510,7 @@ export type Report = CommentableInterface & { reportAccepted?: Maybe; status: ReportStatus; summary?: Maybe; + traineeId: Scalars['ID']['output']; week: Scalars['Int']['output']; year: Scalars['Int']['output']; }; @@ -965,7 +966,7 @@ export type AlexaLinkingUrlQuery = { __typename?: 'Query', alexaLinkingUrl?: str export type ArchivePageDataQueryVariables = Exact<{ [key: string]: never; }>; -export type ArchivePageDataQuery = { __typename?: 'Query', currentUser?: { __typename?: 'Admin', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | { __typename?: 'Trainee', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | { __typename?: 'Trainer', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | undefined, reports: Array<{ __typename: 'Report', id: string, week: number, year: number, status: ReportStatus, department?: string | undefined, summary?: string | undefined, days: Array<{ __typename?: 'Day', status?: DayStatusEnum | undefined, entries: Array<{ __typename?: 'Entry', id: string, time: number, text: string }> }> } | undefined> }; +export type ArchivePageDataQuery = { __typename?: 'Query', currentUser?: { __typename?: 'Admin', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | { __typename?: 'Trainee', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | { __typename?: 'Trainer', id: string, theme?: string | undefined, firstName: string, lastName: string, language?: string | undefined } | undefined, reports: Array<{ __typename: 'Report', id: string, week: number, year: number, status: ReportStatus, department?: string | undefined, summary?: string | undefined, traineeId: string, days: Array<{ __typename?: 'Day', status?: DayStatusEnum | undefined, entries: Array<{ __typename?: 'Entry', id: string, time: number, text: string }> }> } | undefined> }; export type AvatarSettingsDataQueryVariables = Exact<{ [key: string]: never; }>; @@ -1952,6 +1953,7 @@ export const ArchivePageDataDocument = gql` status department summary + traineeId days { status entries { diff --git a/packages/frontend/src/graphql/queries/archive-page-data.gql b/packages/frontend/src/graphql/queries/archive-page-data.gql index 78dcb927..25c8a3c4 100644 --- a/packages/frontend/src/graphql/queries/archive-page-data.gql +++ b/packages/frontend/src/graphql/queries/archive-page-data.gql @@ -13,6 +13,7 @@ query ArchivePageData { status department summary + traineeId days { status entries { diff --git a/packages/frontend/src/locales/de.ts b/packages/frontend/src/locales/de.ts index f23cf213..10579dfa 100644 --- a/packages/frontend/src/locales/de.ts +++ b/packages/frontend/src/locales/de.ts @@ -65,11 +65,14 @@ const germanTranslation: Translation = { }, initial: { title: 'Hier gibt es noch nichts', - caption: 'Deine Berichtshefte erscheinen hier sobald sie akzeptiert wurden und stehen dann zum Export bereit.', + caption_trainee: + 'Deine Berichtshefte erscheinen hier sobald sie akzeptiert wurden und stehen dann zum Export bereit.', + caption_trainer: 'Du hast keine Azubis, die Archive der Azubis werden hier erscheinen sobald sie da sind.', }, }, exportTitle: 'Export gestartet', export: 'Du erhältst in Kürze eine E-mail mit den exportierten Berichten.', + back: 'Zurück', }, report: { title: 'Bericht für', diff --git a/packages/frontend/src/locales/en.ts b/packages/frontend/src/locales/en.ts index ae9b54bc..3e323513 100644 --- a/packages/frontend/src/locales/en.ts +++ b/packages/frontend/src/locales/en.ts @@ -62,11 +62,13 @@ const englishTranslation: Translation = { }, initial: { title: "There's nothing here yet.", - caption: 'Your reports appear here as soon as they have been accepted and are then ready for export.', + caption_trainee: 'Your reports appear here as soon as they have been accepted and are then ready for export.', + caption_trainer: 'You have no Trainees, their archives will appear here when available.', }, }, exportTitle: 'export started', export: 'Shortly you will receive an email with your exported reports.', + back: 'Back', }, report: { title: 'Report for', diff --git a/packages/frontend/src/locales/translation.ts b/packages/frontend/src/locales/translation.ts index eab8129c..20e9f211 100644 --- a/packages/frontend/src/locales/translation.ts +++ b/packages/frontend/src/locales/translation.ts @@ -59,11 +59,13 @@ export default interface Translation { } initial: { title: string - caption: string + caption_trainee: string + caption_trainer: string } } exportTitle: string export: string + back: string } report: { title: string diff --git a/packages/frontend/src/pages/archive-page.tsx b/packages/frontend/src/pages/archive-page.tsx index a7903839..3daf28d2 100644 --- a/packages/frontend/src/pages/archive-page.tsx +++ b/packages/frontend/src/pages/archive-page.tsx @@ -23,12 +23,21 @@ import { PrimaryButton } from '../components/button' import { CheckBox } from '../components/checkbox' import Illustrations from '../components/illustration' import Loader from '../components/loader' -import { DayStatusEnum, Report, ReportStatus, useArchivePageDataQuery } from '../graphql' +import { + DayStatusEnum, + Report, + ReportStatus, + Trainee, + useArchivePageDataQuery, + UserTypeEnum, + useTraineePageDataQuery, +} from '../graphql' import DateHelper from '../helper/date-helper' import { useFetchPdf } from '../hooks/use-fetch-pdf' import { useIsDarkMode } from '../hooks/use-is-dark-mode' import strings from '../locales/localization' import { Template } from '../templates/template' +import TraineeSelector from '../components/trainee-selector' interface ArchiveFilter { department?: string @@ -101,6 +110,9 @@ const searchFilter = const ArchivePage: React.FunctionComponent = () => { const [fetchPdf, pdfLoading] = useFetchPdf() const { loading, data } = useArchivePageDataQuery() + const { data: traineeData, loading: traineeLoading } = useTraineePageDataQuery({ + skip: !data?.currentUser || data.currentUser.__typename !== UserTypeEnum.Trainer, + }) const isDarkMode = useIsDarkMode(data?.currentUser) const [state, setState] = React.useState({ @@ -110,10 +122,23 @@ const ArchivePage: React.FunctionComponent = () => { }) const [allChecked, setAllChecked] = React.useState(false) + const [selectedTrainee, setSelectedTrainee] = React.useState(undefined) const getArchivedReports = React.useCallback(() => { - return data ? data.reports.filter((report) => report?.status === ReportStatus.Archived) : [] - }, [data]) + if (!data) return [] + + const reports = data.reports.filter((report) => report?.status === ReportStatus.Archived) + + if (data.currentUser?.__typename === UserTypeEnum.Trainer && selectedTrainee) { + return reports.filter((report) => report?.traineeId === selectedTrainee) + } + + if (data.currentUser?.__typename === UserTypeEnum.Trainee) { + return reports.filter((report) => report?.traineeId === data.currentUser?.id) + } + + return reports + }, [data, selectedTrainee]) const archivedReports = getArchivedReports() @@ -174,6 +199,10 @@ const ArchivePage: React.FunctionComponent = () => { fetchPdf(getCheckedReports()) } + const handleTraineeSelect = (traineeId: string) => { + setSelectedTrainee(traineeId) + } + const onInput = (event: React.FormEvent) => { const value = (event.target as HTMLInputElement).value.toLowerCase() const yearMonthRegex = /([0-9]{4}):([0-9]{2})/ @@ -220,144 +249,183 @@ const ArchivePage: React.FunctionComponent = () => { }) } + const traineeIdsWithArchivedReports = React.useMemo(() => { + return [...new Set(archivedReports.map((report) => report?.traineeId).filter(Boolean))] as string[] + }, [archivedReports]) + + const filteredTrainees = React.useMemo(() => { + if (!traineeData?.trainees) return [] + return traineeData.trainees.filter( + (trainee) => trainee && traineeIdsWithArchivedReports.includes(trainee.id) + ) as Trainee[] + }, [traineeData, traineeIdsWithArchivedReports]) + return ( ) } diff --git a/packages/frontend/src/pages/report-page.tsx b/packages/frontend/src/pages/report-page.tsx index 5fa15f26..7476ce5d 100644 --- a/packages/frontend/src/pages/report-page.tsx +++ b/packages/frontend/src/pages/report-page.tsx @@ -17,8 +17,12 @@ import { DayStatusEnum, Entry, Report, + ReportPageDataQuery, ReportPageDataQueryVariables, + ReportReviewPageDataQuery, + ReportReviewPageDataQueryVariables, ReportStatus, + Trainee, useDeleteCommentOnDayMutation, useDeleteCommentOnEntryMutation, useDeleteCommentOnReportMutation, @@ -28,7 +32,9 @@ import { useUpdateCommentOnDayMutation, useUpdateCommentOnEntryMutation, useUpdateCommentOnReportMutation, + useReportReviewPageDataQuery, useUpdateReportMutation, + useUserPageQuery, } from '../graphql' import { useFetchPdf } from '../hooks/use-fetch-pdf' import { useToastContext } from '../hooks/use-toast-context' @@ -39,23 +45,44 @@ import { useReportHelper } from '../helper/report-helper' const ReportPage: React.FunctionComponent = () => { const navigate = useNavigate() const { getFinishedDays } = useReportHelper() - - const { year, week } = useParams() + const { trainee, year, week } = useParams() const variables: ReportPageDataQueryVariables = { year: parseInt(year ?? '', 10), week: parseInt(week ?? '', 10), } - const { loading, data } = useReportPageDataQuery({ variables }) + const variables_trainer: ReportReviewPageDataQueryVariables = { + year: parseInt(year ?? '', 10), + week: parseInt(week ?? '', 10), + trainee: trainee ?? '', + } + + const reviewQuery = useReportReviewPageDataQuery({ + variables: variables_trainer, + skip: !trainee, + }) + + const reportQuery = useReportPageDataQuery({ + variables, + skip: !!trainee, + }) - const report = data?.reportForYearAndWeek + const userQuery = useUserPageQuery({ + variables: { id: trainee ?? '' }, + skip: !trainee, + }) - const currentUser = data?.currentUser + const { loading, data } = trainee ? reviewQuery : reportQuery + + const report = trainee + ? (data as ReportReviewPageDataQuery | undefined)?.reportForTrainee + : (data as ReportPageDataQuery | undefined)?.reportForYearAndWeek + + const currentUser = trainee ? (userQuery.data?.getUser as Trainee) : data?.currentUser const [updateReportMutation] = useUpdateReportMutation() const { addToast } = useToastContext() - const [fetchPdf, pdfLoading] = useFetchPdf() const [updateCommentOnReportMutation] = useUpdateCommentOnReportMutation() @@ -67,7 +94,6 @@ const ReportPage: React.FunctionComponent = () => { const [deleteCommentOnEntryMutation] = useDeleteCommentOnEntryMutation() const [publishAllCommentsMutation] = usePublishAllCommentsMutation() - const [showHandoverModal, setShowHandoverModal] = React.useState(false) const [showUnarchiveModal, setShowUnarchiveModal] = React.useState(false) @@ -76,7 +102,6 @@ const ReportPage: React.FunctionComponent = () => { const updateReport = async (values: Partial) => { if (!report) return - return updateReportMutation({ variables: { ...report, @@ -246,11 +271,12 @@ const ReportPage: React.FunctionComponent = () => { const reportTodo = report?.status === ReportStatus.Todo const renderReportPageBody = () => { - if (!report || !currentUser || currentUser.__typename !== 'Trainee') return + if (!report || !currentUser || (currentUser.__typename !== 'Trainee' && currentUser.__typename !== 'Trainer')) + return return ( - + {report?.days.map((day) => ( @@ -266,7 +292,7 @@ const ReportPage: React.FunctionComponent = () => { ))} - {data.currentUser && ( + {data?.currentUser && ( { {strings.report.handover} )} - {reportArchived && ( + {reportArchived && trainee === undefined && ( <> {strings.report.unarchive} @@ -322,7 +348,7 @@ const ReportPage: React.FunctionComponent = () => {