From 6be3fd12f9f9d05aa81ed87433b268755a3fceb0 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Mon, 18 Jan 2021 00:22:57 +0100 Subject: [PATCH] Add internship summaries --- src/api/dto/edition.ts | 31 ++- src/api/dto/internship-registration.ts | 1 + src/data/student.ts | 2 + src/forms/report.tsx | 7 +- src/management/api/course.ts | 9 +- src/management/api/document.ts | 2 +- src/management/api/edition.ts | 11 +- src/management/api/internship.ts | 19 +- src/management/edition/common/StepState.tsx | 68 ++++++ src/management/edition/form.tsx | 6 +- src/management/edition/internship/details.tsx | 39 ---- src/management/edition/internship/grade.tsx | 43 ++++ src/management/edition/internship/list.tsx | 198 ++++++++++++++---- src/management/edition/ipp/list.tsx | 2 +- src/management/edition/manage.tsx | 19 +- .../{internship => proposal}/common.tsx | 20 +- src/management/edition/proposal/details.tsx | 35 ++++ src/management/edition/proposal/list.tsx | 162 ++++++++++++++ src/management/edition/report-schema.tsx | 60 ++++++ src/management/edition/report/list.tsx | 10 +- src/management/edition/settings.tsx | 17 +- src/management/main.tsx | 2 +- src/management/report/fields/list.tsx | 2 +- src/management/routing.tsx | 8 +- src/pages/steps/report.tsx | 56 +++-- src/serialization/edition.ts | 6 + translations/management.pl.yaml | 15 +- 27 files changed, 704 insertions(+), 146 deletions(-) create mode 100644 src/management/edition/common/StepState.tsx delete mode 100644 src/management/edition/internship/details.tsx create mode 100644 src/management/edition/internship/grade.tsx rename src/management/edition/{internship => proposal}/common.tsx (73%) create mode 100644 src/management/edition/proposal/details.tsx create mode 100644 src/management/edition/proposal/list.tsx create mode 100644 src/management/edition/report-schema.tsx diff --git a/src/api/dto/edition.ts b/src/api/dto/edition.ts index cb0cebd..2a361e4 100644 --- a/src/api/dto/edition.ts +++ b/src/api/dto/edition.ts @@ -1,4 +1,4 @@ -import { Identifiable, InternshipProgramEntry } from "@/data"; +import { Identifiable, Identifier, InternshipProgramEntry } from "@/data"; import { CourseDTO, courseDtoTransformer } from "@/api/dto/course"; import { OneWayTransformer, Transformer } from "@/serialization"; import { Edition } from "@/data/edition"; @@ -79,7 +79,7 @@ export interface FieldDefinitionDTO extends Identifiable { export const fieldDefinitionDtoTransformer: Transformer = { transform(dto: FieldDefinitionDTO, context?: unknown): ReportFieldDefinition { return { - ...dto, + id: dto.id, choices: (dto.choices || []).map(choice => JSON.parse(choice)), description: { pl: dto.description, @@ -94,7 +94,7 @@ export const fieldDefinitionDtoTransformer: Transformer JSON.stringify(choice)) || [], description: subject.description.pl, descriptionEng: subject.description.en, @@ -164,3 +164,28 @@ export const programEntryDtoTransformer: Transformer = { + transform(subject: Edition, context?: undefined): EditionUpdateDTO { + return { + id: subject.id, + editionFinish: subject.endDate.toISOString(), + editionStart: subject.startDate.toISOString(), + course: courseDtoTransformer.reverseTransform(subject.course), + reportingStart: subject.reportingStart.toISOString(), + availableSubjectsIds: subject.program.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[], + availableInternshipTypesIds: subject.types.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[], + reportSchema: subject.schema.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[], + } + } +} diff --git a/src/api/dto/internship-registration.ts b/src/api/dto/internship-registration.ts index 8368889..e7f5c4b 100644 --- a/src/api/dto/internship-registration.ts +++ b/src/api/dto/internship-registration.ts @@ -125,6 +125,7 @@ export interface InternshipInfoDTO extends Identifiable { documentation: InternshipDocumentDTO[], student: StudentDTO, report: InternshipReportDTO, + grade: number, } export const internshipReportDtoTransformer: OneWayTransformer = { diff --git a/src/data/student.ts b/src/data/student.ts index fe84901..73772e1 100644 --- a/src/data/student.ts +++ b/src/data/student.ts @@ -26,3 +26,5 @@ export function getMissingStudentData(student: Student): (keyof Student)[] { // !!student.course || "course", ].filter(x => x !== true) as (keyof Student)[]; } + +export const fullname = (student: Student) => `${ student.name } ${ student.surname }`; diff --git a/src/forms/report.tsx b/src/forms/report.tsx index f6307a9..9982ad5 100644 --- a/src/forms/report.tsx +++ b/src/forms/report.tsx @@ -24,6 +24,8 @@ import { Field, Form, Formik, useFormik, useFormikContext } from "formik"; import { Multilingual } from "@/data"; import { Transformer } from "@/serialization"; import api from "@/api"; +import { useCurrentEdition } from "@/hooks"; +import { Edition } from "@/data/edition"; export type ReportFieldProps = { field: TField; @@ -115,12 +117,13 @@ const reportFormValuesTransformer: Transformer { - const result = reportFormValuesTransformer.reverseTransform(values); + const result = reportFormValuesTransformer.reverseTransform(values, { report }); await api.report.save(result); }; diff --git a/src/management/api/course.ts b/src/management/api/course.ts index d9afae3..e245843 100644 --- a/src/management/api/course.ts +++ b/src/management/api/course.ts @@ -1,8 +1,11 @@ import { Course } from "@/data"; import { sampleCourse } from "@/provider/dummy"; +import { axios } from "@/api"; +import { EditionDTO, editionDtoTransformer } from "@/api/dto/edition"; + +const COURSE_INDEX_ENDPOINT = "/management/course"; export async function all(): Promise { - return [ - sampleCourse, - ]; + const response = await axios.get(COURSE_INDEX_ENDPOINT); + return response.data; } diff --git a/src/management/api/document.ts b/src/management/api/document.ts index 728fb10..30aa61e 100644 --- a/src/management/api/document.ts +++ b/src/management/api/document.ts @@ -11,7 +11,7 @@ export async function accept(document: OneOrMany, comment?: await Promise.all(documents.map(document => axios.put( prepare(DOCUMENT_ACCEPT_ENDPOINT, { id: document.id || ""}), - JSON.stringify(comment), + JSON.stringify(comment || ""), { headers: { 'Content-Type': 'application/json' } } ))) } diff --git a/src/management/api/edition.ts b/src/management/api/edition.ts index 7317572..9ab890b 100644 --- a/src/management/api/edition.ts +++ b/src/management/api/edition.ts @@ -1,5 +1,5 @@ import { axios } from "@/api"; -import { EditionDTO, editionDtoTransformer } from "@/api/dto/edition"; +import { EditionDTO, editionDtoTransformer, editionUpdateDtoTransformer } from "@/api/dto/edition"; import { Edition } from "@/data/edition"; import { prepare } from "@/routing"; @@ -15,3 +15,12 @@ export async function details(edition: string): Promise { const response = await axios.get(prepare(MANAGEMENT_EDITION_ENDPOINT, { edition })); return editionDtoTransformer.transform(response.data); } + +export async function save(edition: Edition): Promise { + const response = await axios.put( + MANAGEMENT_EDITION_INDEX_ENDPOINT, + editionUpdateDtoTransformer.transform(edition), + ); + + return response.status == 200; +} diff --git a/src/management/api/internship.ts b/src/management/api/internship.ts index 2c95045..aaed4c3 100644 --- a/src/management/api/internship.ts +++ b/src/management/api/internship.ts @@ -24,10 +24,13 @@ export type InternshipSubmission = Nullable & { changed: Moment | null, ipp: InternshipDocument | null, report: Report | null, + grade: number | null, + approvals: InternshipDocument[], } const INTERNSHIP_MANAGEMENT_INDEX_ENDPOINT = "/management/internship"; const INTERNSHIP_MANAGEMENT_ENDPOINT = "/management/internship/:id"; +const INTERNSHIP_GRADE_ENDPOINT = "/management/internship/:id/grade"; const INTERNSHIP_ACCEPT_ENDPOINT = "/management/internship/:id/registration/accept"; const INTERNSHIP_REJECT_ENDPOINT = "/management/internship/:id/registration/reject"; @@ -38,6 +41,7 @@ const internshipInfoDtoTransformer: Transformer doc.type === UploadType.DeanConsent).map(subject => internshipDocumentDtoTransformer.transform(subject))) }; }, reverseTransform(subject: InternshipSubmission, context: undefined): InternshipInfoDTO { @@ -78,7 +83,7 @@ export async function accept(internship: OneOrMany, comment?: string await Promise.all(internships.map(internship => axios.put( prepare(INTERNSHIP_ACCEPT_ENDPOINT, { id: internship.id || ""}), - JSON.stringify(comment), + JSON.stringify(comment || ""), { headers: { 'Content-Type': 'application/json' } } ))) } @@ -92,3 +97,13 @@ export async function discard(internship: OneOrMany, comment: string { headers: { 'Content-Type': 'application/json' } } ))) } + +export async function grade(internship: OneOrMany, grade: number): Promise { + const internships = encapsulate(internship) + + await Promise.all(internships.map(internship => axios.put( + prepare(INTERNSHIP_GRADE_ENDPOINT, { id: internship.id || ""}), + JSON.stringify(grade), + { headers: { 'Content-Type': 'application/json' } } + ))) +} diff --git a/src/management/edition/common/StepState.tsx b/src/management/edition/common/StepState.tsx new file mode 100644 index 0000000..434cf25 --- /dev/null +++ b/src/management/edition/common/StepState.tsx @@ -0,0 +1,68 @@ +import { SubmissionStatus } from "@/state/reducer/submission"; +import { createStyles, makeStyles } from "@material-ui/core/styles"; +import { Theme, Tooltip } from "@material-ui/core"; +import { green, orange, red } from "@material-ui/core/colors"; +import React, { HTMLProps } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { stateIcons } from "@/management/edition/proposal/common"; +import { Remove } from "@material-ui/icons"; +import { Close } from "mdi-material-ui"; + +const useStyles = makeStyles((theme: Theme) => createStyles({ + "root": { + borderWidth: "2px", + borderStyle: "solid", + borderRadius: "100%", + padding: "0.25rem", + display: "inline-block", + width: "2.25rem", + height: "2.25rem", + textAlign: "center", + position: "relative", + transform: "scale(0.8)" + }, + "icon": { + position: "absolute", + bottom: "-12px", + right: "-12px", + fontSize: "0.25rem", + backgroundColor: "white", + borderRadius: "100%", + transform: "scale(0.75)", + padding: "3px", + }, + awaiting: { + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, + }, + declined: { + borderColor: red["600"], + color: red["600"], + }, + draft: {}, + accepted: { + borderColor: green["600"], + color: green["600"] + } +})) + +export type StepStateProps = { + state: SubmissionStatus | null; + label: string; + icon: React.ReactChild, +} & HTMLProps; + +export const StepState = ({ label, state, icon, ...props }: StepStateProps) => { + const { t } = useTranslation(); + const classes = useStyles(); + + return +
+ { icon } +
+ { state ? stateIcons[state] : } +
+
+
+} diff --git a/src/management/edition/form.tsx b/src/management/edition/form.tsx index 8996d2e..1deb4fa 100644 --- a/src/management/edition/form.tsx +++ b/src/management/edition/form.tsx @@ -31,7 +31,7 @@ import { AccountCheck, ArrowDown, ArrowUp, ShieldCheck, TrashCan } from "mdi-mat import { Actions } from "@/components"; import { Add } from "@material-ui/icons"; -export type EditionFormValues = Nullable; +export type EditionFormValues = Omit, "schema">; export const initialEditionFormValues: EditionFormValues = { course: null, @@ -47,7 +47,7 @@ export const initialEditionFormValues: EditionFormValues = { export const editionFormValuesTransformer: Transformer = identityTransformer; -function toggleValueInArray(array: T[], value: T, comparator: (a: T, b: T) => boolean = (a, b) => a == b): T[] { +export function toggleValueInArray(array: T[], value: T, comparator: (a: T, b: T) => boolean = (a, b) => a == b): T[] { return array.findIndex(other => comparator(other, value)) === -1 ? [ ...array, value ] : array.filter(other => !comparator(other, value)); @@ -121,7 +121,7 @@ export const CoursePickerField = ({ field, form, meta, ...props }: FieldProps } getOptionLabel={ course => course.name } value={ field.value } - onChange={ field.onChange } + onChange={ (_, value) => form.setFieldValue(field.name, value, false) } onBlur={ field.onBlur } /> } diff --git a/src/management/edition/internship/details.tsx b/src/management/edition/internship/details.tsx deleted file mode 100644 index fa65b4a..0000000 --- a/src/management/edition/internship/details.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useCallback } from "react"; -import { Link as RouterLink, useRouteMatch } from "react-router-dom"; -import api from "@/management/api"; -import { Page } from "@/pages/base"; -import { EditionManagement, EditionManagementProps } from "@/management/edition/manage"; -import { Container, Link, Typography } from "@material-ui/core"; -import { Async } from "@/components/async"; -import { useTranslation } from "react-i18next"; -import { useAsync } from "@/hooks"; -import { Student } from "@/data"; -import { route } from "@/routing"; -import { ProposalPreview } from "@/components/proposalPreview"; -import { AcceptanceActions } from "@/components/acceptance-action"; -import { useSpacing } from "@/styles"; - -const fullname = (student: Student) => `${student.name} ${student.surname}`; - -export const InternshipDetails = ({ edition }: EditionManagementProps) => { - const { params } = useRouteMatch(); - const internship = useAsync(useCallback(() => api.internship.get(params.internship), [ params.internship ])); - const { t } = useTranslation("management"); - const spacing = useSpacing(2); - - return - { internship => - - - { t("edition.internships.title") } - { fullname(internship.intern) } - - { fullname(internship.intern) } - - - - {} } onDiscard={ () => {} } label="internship" /> - - } - -} diff --git a/src/management/edition/internship/grade.tsx b/src/management/edition/internship/grade.tsx new file mode 100644 index 0000000..fecc8b1 --- /dev/null +++ b/src/management/edition/internship/grade.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; +import { InternshipSubmission } from "@/management/api/internship"; +import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, FormControl, InputLabel, MenuItem, Select } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; + +export type GradeDialogProps = { + internship: InternshipSubmission; + onSubmit: (grade: number) => void; +} & Omit; + +export const GradeDialog = ({ internship, onSubmit, ...props }: GradeDialogProps) => { + const [grade, setGrade] = useState(internship.grade || null); + const { t } = useTranslation("management"); + + const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { + setGrade(event.target.value as number); + }; + + return + { t("internship.grade") } + + + { t("internship.grade") } + + + + + + + +} diff --git a/src/management/edition/internship/list.tsx b/src/management/edition/internship/list.tsx index f62287d..d29af92 100644 --- a/src/management/edition/internship/list.tsx +++ b/src/management/edition/internship/list.tsx @@ -1,29 +1,158 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useAsync, useAsyncState } from "@/hooks"; +import { useAsyncState } from "@/hooks"; import { useSpacing } from "@/styles"; import api from "@/management/api"; -import { Box, Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core"; +import { Box, Button, Container, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Tooltip, Typography } from "@material-ui/core"; import MaterialTable, { Column } from "material-table"; import { actionsColumn } from "@/management/common/helpers"; -import { FileFind, Refresh, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui"; +import { + Account, + BriefcaseAccount, + BriefcaseAccountOutline, CertificateOutline, + FileChartOutline, + FileFind, + FormatPageBreak, + Refresh, + Star, + StickerCheckOutline +} from "mdi-material-ui"; import { Page } from "@/pages/base"; import { Actions } from "@/components"; import { BulkActions } from "@/management/common/BulkActions"; import { Async } from "@/components/async"; import { MaterialTableTitle } from "@/management/common/MaterialTableTitle"; import { EditionManagement, EditionManagementProps } from "@/management/edition/manage"; -import { Link as RouterLink } from "react-router-dom"; -import { route } from "@/routing"; -import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action"; -import { ProposalPreview } from "@/components/proposalPreview"; import { InternshipSubmission } from "@/management/api/internship"; -import { canAccept, canDiscard, StateLabel } from "@/management/edition/internship/common"; import { createPortal } from "react-dom"; -import { Internship } from "@/data"; +import { FileInfo } from "@/components/fileinfo"; +import { StepState } from "@/management/edition/common/StepState"; +import { fullname, Internship, isStudentDataComplete, Student } from "@/data"; +import { GradeDialog } from "@/management/edition/internship/grade"; +import { InternshipDetailsDialog } from "@/management/edition/proposal/details"; +import { AcceptanceActions } from "@/components/acceptance-action"; +import { InternshipDocument } from "@/api/dto/internship-registration"; +import { StudentPreview } from "@/pages/user/profile"; +import { SubmissionStatus } from "@/state/reducer/submission"; const title = "edition.internships.title"; +export const canGrade = (internship: InternshipSubmission) => !!(internship); + +const ProposalAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const handleDiscard = (comment: string) => { + setOpen(false); + api.internship.discard(internship as Internship, comment); + } + + const handleAccept = (comment?: string) => { + setOpen(false); + api.internship.accept(internship as Internship, comment); + } + + return <> +
setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children || }
+ { createPortal( + setOpen(false) }/>, + document.getElementById("modals") as Element, + ) } + ; +} + +const IPPAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const handleDiscard = (comment: string) => { + setOpen(false); + api.document.discard(internship.ipp as InternshipDocument, comment); + } + + const handleAccept = (comment?: string) => { + setOpen(false); + api.document.accept(internship.ipp as InternshipDocument, comment); + } + + return <> + { internship.ipp + ?
setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }
+ : children } + { createPortal( + setOpen(false) }> + { fullname(internship.intern as Student) } + + { internship.ipp && } + + + + + , + document.getElementById("modals") as Element, + ) } + ; +} + +const StudentAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return <> +
setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }
+ { createPortal( + setOpen(false) }> + { fullname(internship.intern as Student) } + + { internship.intern && } + + , + document.getElementById("modals") as Element, + ) } + ; +} + +export const InternshipState = ({ internship }: { internship: InternshipSubmission }) => { + const studentDataState = internship.intern && isStudentDataComplete(internship.intern) ? "accepted" : null; + const proposalState = internship.state; + const ippState = internship.ipp?.state || null; + const reportState = internship.report?.state || null; + const gradeState = internship.grade ? "accepted" : null; + const approvalState = internship.approvals.reduce((status, document) => { + switch (status) { + case "awaiting": + return status; + case "declined": + return document.state === "awaiting" ? document.state : status; + case "draft": + return ["awaiting", "declined"].includes(document.state) ? document.state : status; + default: + return document.state; + } + }, null); + + const { t } = useTranslation(); + const spacing = useSpacing(0.25); + + return
+ + } /> + + + } /> + + + } /> + + } /> + } /> + } + style={ approvalState ? {} : { opacity: 0.2 } } + /> +
+} + export const InternshipManagement = ({ edition }: EditionManagementProps) => { const { t } = useTranslation("management"); const [result, setInternshipsPromise] = useAsyncState(); @@ -36,47 +165,26 @@ export const InternshipManagement = ({ edition }: EditionManagementProps) => { useEffect(updateInternshipList, []); - const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => { + const GradeAction = ({ internship }: { internship: InternshipSubmission }) => { const [open, setOpen] = useState(false); - const handleSubmissionAccept = async (comment?: string) => { + const handleGradeSubmission = async (grade: number) => { + await api.internship.grade(internship as Internship, grade); setOpen(false); - await api.internship.accept(internship as Internship, comment); updateInternshipList(); } return <> - setOpen(true) }> + + setOpen(true) }> + { createPortal( - setOpen(false) }/>, + setOpen(false) }/>, document.getElementById("modals") as Element, ) } ; } - const DiscardAction = ({ internship }: { internship: InternshipSubmission }) => { - const [open, setOpen] = useState(false); - - const handleSubmissionDiscard = async (comment: string) => { - setOpen(false); - await api.internship.discard(internship as Internship, comment); - updateInternshipList(); - } - - return <> - setOpen(true) }> - { createPortal( - setOpen(false) }/>, - document.getElementById("modals") as Element, - ) } - ; - } - - const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => { - const internship = useAsync(useCallback(() => api.internship.get(summary.id || ""), [ summary.id ])) - return { internship => } - } - const columns: Column[] = [ { title: t("internship.column.student"), @@ -91,17 +199,16 @@ export const InternshipManagement = ({ edition }: EditionManagementProps) => { field: "type.label.pl", }, { - title: t("internship.column.changed"), - render: summary => summary.changed?.format("yyyy-MM-DD") + title: t("internship.column.status"), + render: summary => }, { - title: t("internship.column.status"), - render: summary => + title: t("internship.column.grade"), + field: "grade", + width: 0, }, actionsColumn(internship => <> - { canAccept(internship) && } - { canDiscard(internship) && } - + { canGrade(internship) && } ) ]; @@ -126,7 +233,6 @@ export const InternshipManagement = ({ edition }: EditionManagementProps) => { data={ internships } onSelectionChange={ internships => setSelected(internships) } options={ { selection: true, pageSize: 10 } } - detailPanel={ summary => } /> } diff --git a/src/management/edition/ipp/list.tsx b/src/management/edition/ipp/list.tsx index fccfa55..82a2eac 100644 --- a/src/management/edition/ipp/list.tsx +++ b/src/management/edition/ipp/list.tsx @@ -18,7 +18,7 @@ import { route } from "@/routing"; import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action"; import { ProposalPreview } from "@/components/proposalPreview"; import { InternshipSubmission } from "@/management/api/internship"; -import { StateLabel } from "@/management/edition/internship/common"; +import { StateLabel } from "@/management/edition/proposal/common"; import { createPortal } from "react-dom"; import { Internship } from "@/data"; import { FileInfo } from "@/components/fileinfo"; diff --git a/src/management/edition/manage.tsx b/src/management/edition/manage.tsx index 1893043..3de64ec 100644 --- a/src/management/edition/manage.tsx +++ b/src/management/edition/manage.tsx @@ -5,7 +5,16 @@ import { Page } from "@/pages/base"; import { Container, Link, Paper, Typography } from "@material-ui/core"; import { Management, ManagementLink } from "@/management/main"; import { Edition } from "@/data/edition"; -import { AccountMultiple, BriefcaseAccount, CertificateOutline, CogOutline, FileChartOutline, FormatPageBreak } from "mdi-material-ui"; +import { + AccountMultiple, + BriefcaseAccount, + CertificateOutline, + CogOutline, + FileAccountOutline, + FileChartOutline, + FileQuestionOutline, + FormatPageBreak +} from "mdi-material-ui"; import { route, routes, Routes } from "@/routing"; import { useSpacing } from "@/styles"; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; @@ -31,7 +40,6 @@ export const EditionContext = React.createContext(null); export const EditionManagement = ({ edition }: EditionManagementProps) => { const { t } = useTranslation("management"); - const { params } = useRouteMatch(); const spacing = useSpacing(2); const classes = useSectionStyles(); @@ -50,6 +58,9 @@ export const EditionManagement = ({ edition }: EditionManagementProps) => { } route={ route("management:edition_internships", { edition: edition.id || "" }) }> { t("management:edition.internships.title") } + } route={ route("management:edition_proposals", { edition: edition.id || "" }) }> + { t("management:edition.proposals.title") } + } route={ route("management:edition_ipp_index", { edition: edition.id || "" }) }> { t("management:edition.ipp.title") } @@ -64,8 +75,8 @@ export const EditionManagement = ({ edition }: EditionManagementProps) => { { t("edition.manage.management") } - } route={ route("management:edition_report_form", { edition: edition.id || "" }) }> - { t("management:edition.report-fields.title") } + } route={ route("management:edition_schema", { edition: edition.id || "" }) }> + { t("management:edition.settings.schema") } } route={ route("management:edition_settings", { edition: edition.id || "" }) }> { t("management:edition.settings.title") } diff --git a/src/management/edition/internship/common.tsx b/src/management/edition/proposal/common.tsx similarity index 73% rename from src/management/edition/internship/common.tsx rename to src/management/edition/proposal/common.tsx index ba220c8..ca94029 100644 --- a/src/management/edition/internship/common.tsx +++ b/src/management/edition/proposal/common.tsx @@ -6,11 +6,12 @@ import { Chip } from "@material-ui/core"; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; import { green, orange, red } from "@material-ui/core/colors"; import { InternshipSubmission } from "@/management/api/internship"; +import { HourglassEmptyRounded } from "@material-ui/icons"; const useStateLabelStyles = makeStyles((theme: Theme) => createStyles({ awaiting: { - borderColor: orange["800"], - color: orange["800"], + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, }, declined: { borderColor: red["600"], @@ -29,20 +30,21 @@ export type StateLabelProps = { export const isValidState = (state: string | null) => ["accepted", "draft", "awaiting", "declined"].includes(state as string) +export const stateIcons: { [sate in SubmissionStatus]: React.ReactElement } = { + accepted: , + awaiting: , + declined: , + draft: +} + export const StateLabel = ({ state }: StateLabelProps) => { - const icons: { [sate in SubmissionStatus]: React.ReactElement } = { - accepted: , - awaiting: , - declined: , - draft: - } const classes = useStateLabelStyles(); const { t } = useTranslation(); return isValidState(state) - ? + ? : } label={ t(`translation:submission.status.empty`) } variant="outlined"/> } diff --git a/src/management/edition/proposal/details.tsx b/src/management/edition/proposal/details.tsx new file mode 100644 index 0000000..812b870 --- /dev/null +++ b/src/management/edition/proposal/details.tsx @@ -0,0 +1,35 @@ +import React, { useCallback, useEffect } from "react"; +import { Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core"; +import { fullname, Internship, Student } from "@/data"; +import { ProposalPreview } from "@/components/proposalPreview"; +import { AcceptanceActions } from "@/components/acceptance-action"; +import { InternshipSubmission } from "@/management/api/internship"; +import api from "@/management/api"; +import { useAsync, useAsyncState } from "@/hooks"; +import { Async } from "@/components/async"; + +export type InternshipDetailsDialogProps = { + internship: InternshipSubmission; + onAccept: (comment?: string) => void; + onDiscard: (comment: string) => void; +} & DialogProps; + +export const InternshipDetailsDialog = ({ internship, onAccept, onDiscard, ...props }: InternshipDetailsDialogProps) => { + const [ details, setPromise ] = useAsyncState(); + + useEffect(() => { + if (props.open) { + setPromise(api.internship.get(internship.id as string)); + } + }, [ props.open, internship.id ]) + + return + { fullname(internship.intern as Student) } + + { internship => } + + + + + +} diff --git a/src/management/edition/proposal/list.tsx b/src/management/edition/proposal/list.tsx new file mode 100644 index 0000000..8f6a73f --- /dev/null +++ b/src/management/edition/proposal/list.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAsync, useAsyncState } from "@/hooks"; +import { useSpacing } from "@/styles"; +import api from "@/management/api"; +import { Box, Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core"; +import MaterialTable, { Column } from "material-table"; +import { actionsColumn } from "@/management/common/helpers"; +import { FileFind, Refresh, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui"; +import { Page } from "@/pages/base"; +import { Actions } from "@/components"; +import { BulkActions } from "@/management/common/BulkActions"; +import { Async } from "@/components/async"; +import { MaterialTableTitle } from "@/management/common/MaterialTableTitle"; +import { EditionManagement, EditionManagementProps } from "@/management/edition/manage"; +import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action"; +import { ProposalPreview } from "@/components/proposalPreview"; +import { InternshipSubmission } from "@/management/api/internship"; +import { canAccept, canDiscard, StateLabel } from "@/management/edition/proposal/common"; +import { createPortal } from "react-dom"; +import { Internship } from "@/data"; +import { InternshipDetailsDialog } from "@/management/edition/proposal/details"; + +const title = "edition.internships.title"; + +export const ProposalManagement = ({ edition }: EditionManagementProps) => { + const { t } = useTranslation("management"); + const [result, setInternshipsPromise] = useAsyncState(); + const [selected, setSelected] = useState([]); + const spacing = useSpacing(2); + + const updateInternshipList = () => { + setInternshipsPromise(api.internship.all(edition)); + } + + useEffect(updateInternshipList, []); + + const handleSubmissionDiscard = async (internship: InternshipSubmission, comment: string) => { + await api.internship.discard(internship as Internship, comment); + updateInternshipList(); + } + const handleSubmissionAccept = async (internship: InternshipSubmission, comment?: string) => { + await api.internship.accept(internship as Internship, comment); + updateInternshipList(); + } + + const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => { + const [open, setOpen] = useState(false); + + const handleAccept = (comment?: string) => { + setOpen(false); + handleSubmissionAccept(internship, comment); + } + + return <> + setOpen(true) }> + { createPortal( + setOpen(false) }/>, + document.getElementById("modals") as Element, + ) } + ; + } + + const DiscardAction = ({ internship }: { internship: InternshipSubmission }) => { + const [open, setOpen] = useState(false); + + const handleDiscard = (comment: string) => { + setOpen(false); + handleSubmissionDiscard(internship, comment); + } + + return <> + setOpen(true) }> + { createPortal( + setOpen(false) }/>, + document.getElementById("modals") as Element, + ) } + ; + } + + const PreviewAction = ({ internship }: { internship: InternshipSubmission }) => { + const [open, setOpen] = useState(false); + + const handleDiscard = (comment: string) => { + setOpen(false); + handleSubmissionDiscard(internship, comment); + } + + const handleAccept = (comment?: string) => { + setOpen(false); + handleSubmissionAccept(internship, comment); + } + + return <> + setOpen(true) }> + { createPortal( + setOpen(false) }/>, + document.getElementById("modals") as Element, + ) } + ; + } + + const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => { + const internship = useAsync(useCallback(() => api.internship.get(summary.id || ""), [ summary.id ])) + return { internship => } + } + + const columns: Column[] = [ + { + title: t("internship.column.student"), + render: internship => <>{internship.intern?.name} {internship.intern?.surname}, + }, + { + title: t("internship.column.album"), + field: "intern.albumNumber", + }, + { + title: t("internship.column.type"), + field: "type.label.pl", + }, + { + title: t("internship.column.changed"), + render: summary => summary.changed?.format("yyyy-MM-DD") + }, + { + title: t("internship.column.status"), + render: summary => + }, + actionsColumn(internship => <> + { canAccept(internship) && } + { canDiscard(internship) && } + + ) + ]; + + return + + + { t(title) } + + { t(title) } + + + + + + { selected.length > 0 && + + } + { + internships => } + columns={ columns } + data={ internships } + onSelectionChange={ internships => setSelected(internships) } + options={ { selection: true, pageSize: 10 } } + detailPanel={ summary => } + /> + } + + +} diff --git a/src/management/edition/report-schema.tsx b/src/management/edition/report-schema.tsx new file mode 100644 index 0000000..7fb14ef --- /dev/null +++ b/src/management/edition/report-schema.tsx @@ -0,0 +1,60 @@ +import React, { useCallback, useState } from "react"; +import { Page } from "@/pages/base"; +import { Button, Card, CardContent, CardHeader, Checkbox, Container, Typography } from "@material-ui/core"; +import { Async } from "@/components/async"; +import { useTranslation } from "react-i18next"; +import { useAsync } from "@/hooks"; +import api from "@/management/api"; +import { useSpacing } from "@/styles"; +import { EditionManagement, EditionManagementProps } from "./manage"; +import { ReportFieldDefinition } from "@/data/report"; +import { FieldPreview } from "@/management/report/fields/list"; +import { toggleValueInArray } from "@/management/edition/form"; +import { Actions } from "@/components"; +import { useHistory } from "react-router-dom"; + +const title = "edition.settings.schema"; + +export function EditionReportSchema({ edition }: EditionManagementProps) { + const { t } = useTranslation("management"); + const spacing = useSpacing(2); + const history = useHistory(); + + const fields = useAsync(useCallback(() => api.field.all(), [])) + const [selected, setSelected] = useState(edition.schema); + + const isSelected = (field: ReportFieldDefinition) => selected.findIndex(f => f.id === field.id) !== -1; + const handleCheckboxClick = (field: ReportFieldDefinition) => () => { + setSelected(toggleValueInArray(selected, field, (a, b) => a.id === b.id)); + } + + const handleSave = async () => { + await api.edition.save({ ...edition, schema: selected }); + history.push("management:edition_manage", { edition: edition.id as string }) + } + + return + + + { t(title) } + + { t(title) } + + + + { fields => <> + { fields.map(field =>
+ + + + + +
) } + + + + } +
+
+
; +} diff --git a/src/management/edition/report/list.tsx b/src/management/edition/report/list.tsx index 430d8a8..3b019e7 100644 --- a/src/management/edition/report/list.tsx +++ b/src/management/edition/report/list.tsx @@ -13,17 +13,11 @@ import { BulkActions } from "@/management/common/BulkActions"; import { Async } from "@/components/async"; import { MaterialTableTitle } from "@/management/common/MaterialTableTitle"; import { EditionManagement, EditionManagementProps } from "@/management/edition/manage"; -import { Link as RouterLink } from "react-router-dom"; -import { route } from "@/routing"; import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action"; -import { ProposalPreview } from "@/components/proposalPreview"; import { InternshipSubmission } from "@/management/api/internship"; -import { StateLabel } from "@/management/edition/internship/common"; +import { StateLabel } from "@/management/edition/proposal/common"; import { createPortal } from "react-dom"; -import { Internship, Stateful } from "@/data"; -import { FileInfo } from "@/components/fileinfo"; -import { Alert } from "@material-ui/lab"; -import { InternshipDocument } from "@/api/dto/internship-registration"; +import { Stateful } from "@/data"; import { Report } from "@/data/report"; const title = "edition.reports.title"; diff --git a/src/management/edition/settings.tsx b/src/management/edition/settings.tsx index 1d9e2f6..085f3a3 100644 --- a/src/management/edition/settings.tsx +++ b/src/management/edition/settings.tsx @@ -1,16 +1,15 @@ import React, { useCallback } from "react"; import { Page } from "@/pages/base"; -import { Management } from "@/management/main"; import { Container, Divider, Typography, Button } from "@material-ui/core"; import { Async } from "@/components/async"; import { useTranslation } from "react-i18next"; -import { useRouteMatch } from "react-router-dom"; +import { useHistory, useRouteMatch } from "react-router-dom"; import { useAsync } from "@/hooks"; import { Edition } from "@/data/edition"; import api from "@/management/api"; import { Form, Formik } from "formik"; import { useSpacing } from "@/styles"; -import { EditionForm } from "@/management/edition/form"; +import { EditionForm, EditionFormValues, editionFormValuesTransformer } from "@/management/edition/form"; import { Actions } from "@/components"; import { Save } from "@material-ui/icons"; import { Cancel } from "mdi-material-ui"; @@ -21,11 +20,21 @@ const title = "edition.settings.title"; export function EditionSettings() { const { t } = useTranslation("management"); const { params } = useRouteMatch(); + const history = useHistory(); const spacing = useSpacing(2); const edition = useAsync(useCallback(() => api.edition.details(params.edition), [params.edition])) - const handleSubmit = () => {}; + const handleSubmit = async (values: EditionFormValues) => { + const result: Edition = { + ...edition.value, + ...editionFormValuesTransformer.reverseTransform(values) + }; + + await api.edition.save(result); + + history.push("management:edition_manage", { edition: edition.id as string }) + }; return diff --git a/src/management/main.tsx b/src/management/main.tsx index ecfd164..ba98c8f 100644 --- a/src/management/main.tsx +++ b/src/management/main.tsx @@ -47,7 +47,7 @@ export const ManagementIndex = () => { { t("management:type.index.title") }
} route={ route("management:report_fields") }> - { t("management:edition.report-fields.title") } + { t("management:report-fields.title") } } route={ route("management:static_pages") }> { t("management:page.index.title") } diff --git a/src/management/report/fields/list.tsx b/src/management/report/fields/list.tsx index 69b6a97..ef75062 100644 --- a/src/management/report/fields/list.tsx +++ b/src/management/report/fields/list.tsx @@ -20,7 +20,7 @@ import { Actions } from "@/components"; import { Refresh } from "mdi-material-ui"; import { useSpacing } from "@/styles"; -const title = "edition.report-fields.title"; +const title = "report-fields.title"; export const FieldPreview = ({ field }: { field: ReportFieldDefinition }) => { return {}}> diff --git a/src/management/routing.tsx b/src/management/routing.tsx index d1f4d2d..efdf24a 100644 --- a/src/management/routing.tsx +++ b/src/management/routing.tsx @@ -7,11 +7,12 @@ import StaticPageManagement from "@/management/page/list"; import { InternshipTypeManagement } from "@/management/type/list"; import { EditionRouter, EditionManagement } from "@/management/edition/manage"; import { EditionSettings } from "@/management/edition/settings"; -import { InternshipManagement } from "@/management/edition/internship/list"; -import { InternshipDetails } from "@/management/edition/internship/details"; +import { ProposalManagement } from "@/management/edition/proposal/list"; import { PlanManagement } from "@/management/edition/ipp/list"; import { ReportFields } from "@/management/report/fields/list"; import { ReportManagement } from "@/management/edition/report/list"; +import { InternshipManagement } from "@/management/edition/internship/list"; +import { EditionReportSchema } from "@/management/edition/report-schema"; export const managementRoutes: Route[] = ([ { name: "index", path: "/", content: ManagementIndex, exact: true }, @@ -19,9 +20,10 @@ export const managementRoutes: Route[] = ([ { name: "edition_router", path: "/editions/:edition", content: EditionRouter }, { name: "edition_settings", path: "/editions/:edition/settings", content: EditionSettings, tags: ["edition"] }, { name: "edition_manage", path: "/editions/:edition", content: EditionManagement, tags: ["edition"], exact: true }, - { name: "edition_internship", path: "/editions/:edition/internships/:internship", content: InternshipDetails, tags: ["edition"] }, { name: "edition_internships", path: "/editions/:edition/internships", content: InternshipManagement, tags: ["edition"] }, + { name: "edition_proposals", path: "/editions/:edition/proposals", content: ProposalManagement, tags: ["edition"] }, { name: "edition_reports", path: "/editions/:edition/reports", content: ReportManagement, tags: ["edition"] }, + { name: "edition_schema", path: "/editions/:edition/schema", content: EditionReportSchema, tags: ["edition"] }, { name: "edition_ipp_index", path: "/editions/:edition/ipp", content: PlanManagement, tags: ["edition"] }, { name: "editions", path: "/editions", content: EditionsManagement }, diff --git a/src/pages/steps/report.tsx b/src/pages/steps/report.tsx index c1e99c6..a5703c2 100644 --- a/src/pages/steps/report.tsx +++ b/src/pages/steps/report.tsx @@ -12,25 +12,53 @@ import { Alert, AlertTitle } from "@material-ui/lab"; import { ContactButton, Status } from "@/pages/steps/common"; import { useCurrentEdition, useDeadlines } from "@/hooks"; import { useSpacing } from "@/styles"; -import { Report } from "@/data/report"; -import { sampleReportSchema } from "@/provider/dummy/report"; +import { MultiChoiceValue, Report, ReportSchema, SingleChoiceValue, TextFieldValue } from "@/data/report"; import { createPortal } from "react-dom"; import { getInternshipReport } from "@/state/reducer/report"; +import { Edition } from "@/data/edition"; + +export type ReportPreviewProps = { + schema: ReportSchema, + report: Report, +} + +export const ReportPreview = ({ schema, report }: ReportPreviewProps) => { + return <>{ schema.map(field => { + const value = report.fields[`field_${ field.id }`]; + const { t } = useTranslation(); + + const Value = () => { + switch (field.type) { + case "checkbox": + return
    { ((value as MultiChoiceValue).map(selection =>
  • { selection.pl }
  • )) }
+ case "radio": + case "select": + return
{ (value as SingleChoiceValue).pl }
+ case "long-text": + case "short-text": + return

{ value as TextFieldValue }

+ } + } + + return <> + { field.label.pl } + { value ? : t("no-value") } + + }) } +} export type ReportPreviewDialogProps = { report: Report; } & DialogProps; export const ReportPreviewDialog = ({ report, ...props }: ReportPreviewDialogProps) => { - const schema = sampleReportSchema; + const edition = useCurrentEdition() as Edition; + const schema = edition.schema || []; const { t } = useTranslation(); - return - { t("steps.report.preview") } - { schema.map(field => <> - { field.label.pl } - { JSON.stringify(report.fields[`field_${field.id}`]) } - )} + return + { t("steps.report.header") } + } @@ -40,7 +68,8 @@ const ReportActions = () => { const { t } = useTranslation(); const FormAction = ({ children = t('steps.report.submit'), ...props }: ButtonProps) => - @@ -64,7 +93,8 @@ const ReportActions = () => { switch (status) { case "awaiting": return - + + { t('send-again') } case "accepted": return @@ -77,7 +107,7 @@ const ReportActions = () => { case "draft": return - + default: @@ -112,7 +142,7 @@ export const ReportStep = (props: StepProps) => { active={ true } completed={ sent } declined={ declined } waiting={ status == "awaiting" } until={ deadlines.report } notBefore={ edition?.reportingStart } - state={ }> + state={ }>

{ t(`steps.report.info.${ status }`) }

diff --git a/src/serialization/edition.ts b/src/serialization/edition.ts index 6c1ced6..6e06c4b 100644 --- a/src/serialization/edition.ts +++ b/src/serialization/edition.ts @@ -14,6 +14,9 @@ export const editionSerializationTransformer: SerializationTransformer reportingStart: momentSerializationTransformer.transform(subject.reportingStart), startDate: momentSerializationTransformer.transform(subject.startDate), endDate: momentSerializationTransformer.transform(subject.endDate), + schema: subject.schema, + types: subject.types, + program: subject.program } }, reverseTransform(subject: Serializable, context?: unknown): Edition { @@ -26,6 +29,9 @@ export const editionSerializationTransformer: SerializationTransformer reportingStart: momentSerializationTransformer.reverseTransform(subject.reportingStart) as Moment, startDate: momentSerializationTransformer.reverseTransform(subject.startDate) as Moment, endDate: momentSerializationTransformer.reverseTransform(subject.endDate) as Moment, + schema: subject.schema as any, + types: subject.types, + program: subject.program } }, } diff --git a/translations/management.pl.yaml b/translations/management.pl.yaml index cae6424..141007f 100644 --- a/translations/management.pl.yaml +++ b/translations/management.pl.yaml @@ -12,17 +12,22 @@ actions: delete: Usuń edit: Edytuj add: Dodaj + manage: Zarządzaj internship: + grade: Oceń praktykę column: student: Imię i Nazwisko album: Numer Albumu type: Rodzaj praktyki status: Status changed: Data aktualizacji + grade: Ocena edition: internships: + title: Praktyki + proposals: title: Zgłoszenia praktyk ipp: title: Indywidualne Plany Praktyk @@ -30,6 +35,8 @@ edition: title: "Edycje praktyk" reports: title: "Raporty praktyki" + dean-approvals: + title: "Zgody dziekana" field: id: Identyfikator start: Początek @@ -44,15 +51,19 @@ edition: deadlines: "Terminy" program: "Ramowy program praktyk" types: "Dostępne typy praktyki" - report-fields: - title: "Pola formularza raportu praktyki" manage: management: "Zarządzanie edycją" internships: "Zarządzanie praktykami" settings: title: "Konfiguracja edycji" + schema: "Pola formularza raportu praktyki" program: entry: "Punkt ramowego programu praktyki #{{ index }}" + field: + description: "Opis" + +report-fields: + title: "Pola formularza raportu praktyki" report-field: preview: Podgląd