Add ipp management

This commit is contained in:
Kacper Donat 2021-01-10 17:34:05 +01:00
parent dffc279b4a
commit d6de1fb959
17 changed files with 256 additions and 70 deletions

View File

@ -82,9 +82,16 @@ export interface InternshipRegistrationDTO extends Identifiable {
declaredHours: number,
subjects: { subject: ProgramEntryDTO }[],
submissionDate: string,
changeStateComment: string;
}
export interface InternshipDocument extends Identifiable {
description: null,
type: UploadType,
state: SubmissionStatus,
}
export interface InternshipDocumentDTO extends Identifiable {
description: null,
type: UploadType,
state: SubmissionState,
@ -94,7 +101,7 @@ const reference = (subject: Identifiable | null): Identifiable | null => subject
export interface InternshipInfoDTO extends Identifiable {
internshipRegistration: InternshipRegistrationDTO;
documentation: InternshipDocument[],
documentation: InternshipDocumentDTO[],
student: StudentDTO,
}
@ -139,3 +146,12 @@ export const internshipRegistrationDtoTransformer: OneWayTransformer<InternshipR
};
}
}
export const internshipDocumentDtoTransformer: OneWayTransformer<InternshipDocumentDTO, InternshipDocument> = {
transform(dto: InternshipDocumentDTO, context?: unknown): InternshipDocument {
return {
...dto,
state: submissionStateDtoTransformer.transform(dto.state),
}
}
}

View File

@ -7,7 +7,7 @@ import {
DialogActions,
DialogContent,
DialogProps,
DialogTitle,
DialogTitle, FormControl,
Menu,
MenuItem,
TextField,
@ -17,6 +17,10 @@ import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-materia
import { useTranslation } from "react-i18next";
import { useVerticalSpacing } from "@/styles";
import { createPortal } from "react-dom";
// @ts-ignore
import { CKEditor } from '@ckeditor/ckeditor5-react';
// @ts-ignore
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
type AcceptSubmissionDialogProps = {
onAccept: (comment?: string) => void;
@ -28,11 +32,11 @@ export function AcceptSubmissionDialog({ onAccept, label, ...props }: AcceptSubm
const [comment, setComment] = useState<string>("");
const classes = useVerticalSpacing(3);
return <Dialog maxWidth="md" { ...props }>
return <Dialog maxWidth="xl" { ...props }>
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
<TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") } rows={3}/>
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
<DialogActions>
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>
@ -56,11 +60,11 @@ export function DiscardSubmissionDialog({ onDiscard, label, ...props }: DiscardS
const [comment, setComment] = useState<string>("");
const classes = useVerticalSpacing(3);
return <Dialog maxWidth="md" { ...props }>
return <Dialog maxWidth="xl" { ...props }>
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
<TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") } rows={3}/>
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
<DialogActions>
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>

View File

@ -0,0 +1,27 @@
import { encapsulate, OneOrMany } from "@/helpers";
import { axios } from "@/api";
import { prepare } from "@/routing";
import { InternshipDocument } from "@/api/dto/internship-registration";
const DOCUMENT_ACCEPT_ENDPOINT = "/management/document/accept/:id";
const DOCUMENT_REJECT_ENDPOINT = "/management/document/reject/:id";
export async function accept(document: OneOrMany<InternshipDocument>, comment?: string): Promise<void> {
const documents = encapsulate(document)
await Promise.all(documents.map(document => axios.put(
prepare(DOCUMENT_ACCEPT_ENDPOINT, { id: document.id || ""}),
JSON.stringify(comment),
{ headers: { 'Content-Type': 'application/json' } }
)))
}
export async function discard(document: OneOrMany<InternshipDocument>, comment: string): Promise<void> {
const documents = encapsulate(document)
await Promise.all(documents.map(document => axios.put(
prepare(DOCUMENT_REJECT_ENDPOINT, { id: document.id || ""}),
JSON.stringify(comment),
{ headers: { 'Content-Type': 'application/json' } }
)))
}

View File

@ -3,13 +3,15 @@ import * as page from "./page"
import * as type from "./type"
import * as course from "./course"
import * as internship from "./internship"
import * as document from "./document"
export const api = {
edition,
page,
type,
course,
internship
internship,
document
}
export default api;

View File

@ -1,55 +1,27 @@
import { Identifiable, Identifier, Internship } from "@/data";
import { sampleCompanies, sampleStudent } from "@/provider/dummy";
import moment, { Moment } from "moment-timezone";
import { encapsulate, Nullable, OneOrMany } from "@/helpers";
import { SubmissionStatus } from "@/state/reducer/submission";
import { axios } from "@/api";
import { prepare, query } from "@/routing";
import { InternshipInfoDTO, submissionStateDtoTransformer } from "@/api/dto/internship-registration";
import {
InternshipDocument,
InternshipDocumentDTO,
internshipDocumentDtoTransformer,
InternshipInfoDTO,
submissionStateDtoTransformer
} from "@/api/dto/internship-registration";
import { Transformer } from "@/serialization";
import { mentorDtoTransformer } from "@/api/dto/mentor";
import { internshipTypeDtoTransformer } from "@/api/dto/type";
import { studentDtoTransfer } from "@/api/dto/student";
import { programEntryDtoTransformer } from "@/api/dto/edition";
import { UploadType } from "@/api/upload";
export type InternshipSubmission = Nullable<Internship> & {
state: SubmissionStatus,
changed: Moment | null,
}
const sampleInternship: InternshipSubmission = {
id: "test",
company: sampleCompanies[0],
startDate: moment('2020-07-01'),
endDate: moment('2020-08-31'),
hours: 180,
intern: sampleStudent,
isAccepted: false,
lengthInWeeks: 0,
mentor: {
email: "test@test.com",
name: "Test",
surname: "Testowy",
phone: "+48 123 456 789"
},
office: sampleCompanies[0].offices[0],
program: [
{ description: "Test" },
],
type: {
description: {
pl: "Przykładowy typ",
en: "Example type"
},
label: {
pl: "Przykładowy",
en: "Example"
},
requiresDeanApproval: true,
requiresInsurance: false
},
state: "declined",
changed: moment(),
ipp: InternshipDocument | null,
}
const INTERNSHIP_MANAGEMENT_INDEX_ENDPOINT = "/management/internship";
@ -59,6 +31,9 @@ const INTERNSHIP_REJECT_ENDPOINT = "/management/internship/reject/:id"
const internshipInfoDtoTransformer: Transformer<InternshipInfoDTO, InternshipSubmission> = {
transform(subject: InternshipInfoDTO, context?: never): InternshipSubmission {
// @ts-ignore
const ipp = subject.documentation.find<InternshipDocumentDTO>(doc => doc.type === UploadType.Ipp);
return {
changed: moment(subject.internshipRegistration.submissionDate),
company: subject.internshipRegistration.company,
@ -74,6 +49,7 @@ const internshipInfoDtoTransformer: Transformer<InternshipInfoDTO, InternshipSub
program: (subject.internshipRegistration.subjects || []).map(subject => programEntryDtoTransformer.transform(subject.subject)),
state: submissionStateDtoTransformer.transform(subject.internshipRegistration.state),
type: subject.internshipRegistration.type && internshipTypeDtoTransformer.transform(subject.internshipRegistration.type),
ipp: ipp && internshipDocumentDtoTransformer.transform(ipp),
};
},
reverseTransform(subject: InternshipSubmission, context: undefined): InternshipInfoDTO {
@ -96,11 +72,19 @@ export async function get(id: Identifier): Promise<InternshipSubmission> {
export async function accept(internship: OneOrMany<Internship>, comment?: string): Promise<void> {
const internships = encapsulate(internship)
await Promise.all(internships.map(internship => axios.put(prepare(INTERNSHIP_ACCEPT_ENDPOINT, { id: internship.id || ""}))))
await Promise.all(internships.map(internship => axios.put(
prepare(INTERNSHIP_ACCEPT_ENDPOINT, { id: internship.id || ""}),
JSON.stringify(comment),
{ headers: { 'Content-Type': 'application/json' } }
)))
}
export async function discard(internship: OneOrMany<Internship>, comment: string): Promise<void> {
const internships = encapsulate(internship)
await Promise.all(internships.map(internship => axios.put(prepare(INTERNSHIP_REJECT_ENDPOINT, { id: internship.id || ""}))))
await Promise.all(internships.map(internship => axios.put(
prepare(INTERNSHIP_REJECT_ENDPOINT, { id: internship.id || ""}),
JSON.stringify(comment),
{ headers: { 'Content-Type': 'application/json' } }
)))
}

View File

@ -1,6 +1,6 @@
import { SubmissionStatus } from "@/state/reducer/submission";
import React from "react";
import { ClockOutline, NotebookCheckOutline, NotebookEditOutline, NotebookRemoveOutline } from "mdi-material-ui";
import { ClockOutline, FileQuestion, NotebookCheckOutline, NotebookEditOutline, NotebookRemoveOutline } from "mdi-material-ui";
import { useTranslation } from "react-i18next";
import { Chip } from "@material-ui/core";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
@ -24,9 +24,11 @@ const useStateLabelStyles = makeStyles((theme: Theme) => createStyles<Submission
}))
export type StateLabelProps = {
state: SubmissionStatus;
state: SubmissionStatus | null;
};
export const isValidState = (state: string | null) => ["accepted", "draft", "awaiting", "declined"].includes(state as string)
export const StateLabel = ({ state }: StateLabelProps) => {
const icons: { [sate in SubmissionStatus]: React.ReactElement } = {
accepted: <NotebookCheckOutline/>,
@ -39,7 +41,9 @@ export const StateLabel = ({ state }: StateLabelProps) => {
const { t } = useTranslation();
return <Chip icon={ icons[state] } label={ t(`translation:submission.status.${ state }`) } variant="outlined" className={ classes[state] }/>
return isValidState(state)
? <Chip icon={ icons[state as SubmissionStatus] } label={ t(`translation:submission.status.${ state }`) } variant="outlined" className={ classes[state as SubmissionStatus] }/>
: <Chip icon={ <FileQuestion /> } label={ t(`translation:submission.status.empty`) } variant="outlined"/>
}
export const canEdit = (internship: InternshipSubmission) => internship.state != "draft";

View File

@ -0,0 +1,143 @@
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, Typography } from "@material-ui/core";
import MaterialTable, { Column } from "material-table";
import { actionsColumn } from "@/management/common/helpers";
import { FileDownloadOutline, 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 { 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 { createPortal } from "react-dom";
import { Internship } from "@/data";
import { FileInfo } from "@/components/fileinfo";
import { Alert } from "@material-ui/lab";
import { InternshipDocument } from "@/api/dto/internship-registration";
const title = "edition.ipp.title";
export const canEdit = (ipp: InternshipDocument | null) => !!(ipp && ipp.state != "draft");
export const canDownload = (ipp: InternshipDocument | null) => !!(ipp && ipp.id);
export const canAccept = (ipp: InternshipDocument | null) => !!(ipp && ["declined", "awaiting"].includes(ipp.state));
export const canDiscard = (ipp: InternshipDocument | null) => !!(ipp && ["accepted", "awaiting"].includes(ipp.state));
export const PlanManagement = ({ edition }: EditionManagementProps) => {
const { t } = useTranslation("management");
const [result, setInternshipsPromise] = useAsyncState<InternshipSubmission[]>();
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
const spacing = useSpacing(2);
const updateInternshipList = () => {
setInternshipsPromise(api.internship.all(edition));
}
useEffect(updateInternshipList, []);
const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => {
const [open, setOpen] = useState(false);
const handleSubmissionAccept = async (comment?: string) => {
setOpen(false);
await api.document.accept(internship.ipp as InternshipDocument, comment);
updateInternshipList();
}
return <>
<IconButton onClick={ () => setOpen(true) }><StickerCheckOutline /></IconButton>
{ createPortal(
<AcceptSubmissionDialog onAccept={ handleSubmissionAccept } label="plan" open={ open } onClose={ () => 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.document.discard(internship.ipp as InternshipDocument, comment);
updateInternshipList();
}
return <>
<IconButton onClick={ () => setOpen(true) }><StickerRemoveOutline /></IconButton>
{ createPortal(
<DiscardSubmissionDialog onDiscard={ handleSubmissionDiscard } label="plan" open={ open } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element,
) }
</>;
}
const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => {
return <Box m={ 3 }>
{ summary.ipp ? <FileInfo document={ summary.ipp }/> : <Alert severity="warning" title={ t("ipp.no-submission.title") }>{ t("ipp.no-submission.info") }</Alert> }
</Box>
}
const columns: Column<InternshipSubmission>[] = [
{
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 => <StateLabel state={ summary.ipp?.state } />
},
actionsColumn(internship => <>
{ canAccept(internship.ipp) && <AcceptAction internship={ internship } /> }
{ canDiscard(internship.ipp) && <DiscardAction internship={ internship } /> }
{ canDownload(internship.ipp) && <IconButton component={ RouterLink } to={ route("management:edition_internship", { edition: edition.id || "", internship: internship.id || "" }) }><FileDownloadOutline /></IconButton> }
</>)
];
return <Page>
<Page.Header maxWidth="lg">
<EditionManagement.Breadcrumbs>
<Typography color="textPrimary">{ t(title) }</Typography>
</EditionManagement.Breadcrumbs>
<Page.Title>{ t(title) }</Page.Title>
</Page.Header>
<Container maxWidth="lg" className={ spacing.vertical }>
<Actions>
<Button onClick={ updateInternshipList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
</Actions>
{ selected.length > 0 && <BulkActions>
</BulkActions> }
<Async async={ result } keepValue>{
internships => <MaterialTable
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
columns={ columns }
data={ internships }
onSelectionChange={ internships => setSelected(internships) }
options={ { selection: true, pageSize: 10 } }
detailPanel={ summary => <InternshipDetails summary={ summary } /> }
/>
}</Async>
</Container>
</Page>
}

View File

@ -53,7 +53,7 @@ export const EditionManagement = ({ edition }: EditionManagementProps) => {
<ManagementLink icon={ <BriefcaseAccount/> } route={ route("management:edition_internships", { edition: edition.id || "" }) }>
{ t("management:edition.internships.title") }
</ManagementLink>
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:edition_report_form", { edition: edition.id || "" }) }>
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:edition_ipp_index", { edition: edition.id || "" }) }>
{ t("management:edition.ipp.title") }
</ManagementLink>
<ManagementLink icon={ <FileChartOutline/> } route={ route("management:edition_report_form", { edition: edition.id || "" }) }>

View File

@ -10,6 +10,7 @@ import { EditionReportFields } from "@/management/edition/report/fields/list";
import { EditionSettings } from "@/management/edition/settings";
import { InternshipManagement } from "@/management/edition/internship/list";
import { InternshipDetails } from "@/management/edition/internship/details";
import { PlanManagement } from "@/management/edition/ipp/list";
export const managementRoutes: Route[] = ([
{ name: "index", path: "/", content: ManagementIndex, exact: true },
@ -20,6 +21,7 @@ export const managementRoutes: Route[] = ([
{ 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_ipp_index", path: "/editions/:edition/ipp", content: PlanManagement, tags: ["edition"] },
{ name: "editions", path: "/editions", content: EditionsManagement },
{ name: "types", path: "/types", content: InternshipTypeManagement },

View File

@ -51,20 +51,6 @@ export const InternshipProposalFormPage = () => {
export const InternshipProposalPreviewPage = () => {
const { t } = useTranslation();
const proposal = useSelector<AppState, Internship | null>(state => state.proposal.proposal && internshipSerializationTransformer.reverseTransform(state.proposal.proposal));
const dispatch = useDispatch();
const history = useHistory();
const handleAccept = (comment?: string) => {
dispatch({ type: InternshipProposalActions.Approve, comment: comment || null });
history.push(route("home"));
}
const handleDiscard = (comment: string) => {
dispatch({ type: InternshipProposalActions.Decline, comment: comment });
history.push(route("home"));
}
const classes = useVerticalSpacing(3);
return <Page title={ t("") }>
@ -80,8 +66,6 @@ export const InternshipProposalPreviewPage = () => {
{ proposal && <ProposalPreview proposal={ proposal } /> }
<Actions>
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="internship" />
<Button component={ RouterLink } to={ route("home") }>
{ t('go-back') }
</Button>

View File

@ -13,7 +13,6 @@ import { PlanStep } from "@/pages/steps/plan";
import { InsuranceState } from "@/state/reducer/insurance";
import { InsuranceStep } from "@/pages/steps/insurance";
import { StudentStep } from "@/pages/steps/student";
import { useCurrentEdition, useDeadlines } from "@/hooks";
import api from "@/api";
import { AppDispatch, InternshipPlanActions, InternshipProposalActions, useDispatch } from "@/state/actions";
import { internshipRegistrationDtoTransformer } from "@/api/dto/internship-registration";
@ -26,6 +25,7 @@ export const updateInternshipInfo = async (dispatch: AppDispatch) => {
dispatch({
type: InternshipProposalActions.Receive,
state: internship.internshipRegistration.state,
comment: internship.internshipRegistration.changeStateComment,
internship: internshipRegistrationDtoTransformer.transform(internship.internshipRegistration),
})

View File

@ -48,7 +48,6 @@ const PlanActions = () => {
switch (status) {
case "awaiting":
return <Actions>
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="plan" />
</Actions>
case "accepted":
return <Actions>

View File

@ -60,7 +60,7 @@ export const ProposalComment = (props: HTMLProps<HTMLDivElement>) => {
return comment ? <Alert severity={ declined ? "error" : "warning" } { ...props as any }>
<AlertTitle>{ t('comments') }</AlertTitle>
{ comment }
<div dangerouslySetInnerHTML={{ __html: comment }}/>
</Alert> : null
}

View File

@ -27,7 +27,8 @@ export interface ReceiveProposalDeclineAction extends ReceiveSubmissionDeclineAc
export interface ReceiveProposalUpdateAction extends ReceiveSubmissionUpdateAction<InternshipProposalActions.Receive> {
internship: Internship;
state: SubmissionState,
state: SubmissionState;
comment?: string;
}
export interface SaveProposalAction extends SaveSubmissionAction<InternshipProposalActions.Save> {

View File

@ -58,6 +58,7 @@ const internshipProposalReducer = (state: InternshipProposalState = defaultInter
ApiSubmissionState.Submitted
].includes(action.state),
proposal: internshipSerializationTransformer.transform(action.internship),
comment: action.comment || "",
}
default:
return state;

View File

@ -24,6 +24,8 @@ internship:
edition:
internships:
title: Zgłoszenia praktyk
ipp:
title: Indywidualne Plany Praktyk
index:
title: "Edycje praktyk"
field:

View File

@ -111,6 +111,10 @@ forms:
fields:
key: Klucz dostępu do edycji
report:
instructions: >
Wypełnij wszystkie pola formularza w celu sfinalizowania praktyki.
student:
name: imię
surname: mazwisko
@ -125,6 +129,7 @@ submission:
accepted: "zaakceptowano"
declined: "do poprawy"
draft: "wersja robocza"
empty: "brak zgłoszenia"
internship:
validation:
@ -218,6 +223,18 @@ steps:
download: Twój indywidualny program praktyki
report:
header: "Raport z praktyki"
info:
draft: >
Po ukończeniu praktyki należy wypełnić z niej raport oraz przesłać ocenę praktyki przygotowaną przez Twojego zakładowego opiekuna praktyki.
awaiting: >
Twój raport musi zostać zweryfikowany i zatwierdzony. Po weryfikacji zostaniesz poinformowany o
akceptacji bądź konieczności wprowadzenia zmian.
accepted: >
Twój raport został zweryfikowany i zaakceptowany.
declined: >
Twój raport został zweryfikowany i odrzucony. Popraw zgłoszone uwagi i wyślij raport ponownie. W razie
pytań możesz również skontaktować się z pełnomocnikiem ds. praktyk Twojego kierunku.
submit: Uzupełnij raport
grade:
header: "Ocena z praktyki"
insurance: