Merge pull request 'feature/reporting' (#20) from feature/reporting into master

This commit is contained in:
Kacper Donat 2021-01-18 00:23:23 +01:00
commit 1b9036e8e1
65 changed files with 2881 additions and 151 deletions

View File

@ -1,9 +1,11 @@
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";
import moment from "moment-timezone";
import { Subset } from "@/helpers";
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
import { ReportFieldDefinition, ReportFieldType } from "@/data/report";
export interface ProgramEntryDTO extends Identifiable {
description: string;
@ -16,6 +18,8 @@ export interface EditionDTO extends Identifiable {
reportingStart: string,
course: CourseDTO,
availableSubjects: ProgramEntryDTO[],
availableInternshipTypes: InternshipTypeDTO[],
reportSchema: FieldDefinitionDTO[],
}
export interface EditionTeaserDTO extends Identifiable {
@ -24,6 +28,83 @@ export interface EditionTeaserDTO extends Identifiable {
courseName: string,
}
export enum FieldDefinitionDTOType {
LongText = "LongText",
ShortText = "ShortText",
Select = "Select",
Radial = "Radial",
Checkbox = "Checkbox",
}
export const fieldDefinitionDtoTypeTransformer: Transformer<FieldDefinitionDTOType, ReportFieldType> = {
transform(dto: FieldDefinitionDTOType, context?: unknown) {
switch (dto) {
case FieldDefinitionDTOType.LongText:
return "long-text"
case FieldDefinitionDTOType.ShortText:
return "short-text";
case FieldDefinitionDTOType.Select:
return "select";
case FieldDefinitionDTOType.Radial:
return "radio";
case FieldDefinitionDTOType.Checkbox:
return "checkbox";
}
},
reverseTransform(type: ReportFieldType, context?: unknown) {
switch (type) {
case "short-text":
return FieldDefinitionDTOType.ShortText;
case "long-text":
return FieldDefinitionDTOType.LongText;
case "checkbox":
return FieldDefinitionDTOType.Checkbox;
case "radio":
return FieldDefinitionDTOType.Radial;
case "select":
return FieldDefinitionDTOType.Select;
}
}
}
export interface FieldDefinitionDTO extends Identifiable {
label: string;
labelEng: string;
description: string;
descriptionEng: string;
fieldType: FieldDefinitionDTOType;
choices: string[];
}
export const fieldDefinitionDtoTransformer: Transformer<FieldDefinitionDTO, ReportFieldDefinition> = {
transform(dto: FieldDefinitionDTO, context?: unknown): ReportFieldDefinition {
return {
id: dto.id,
choices: (dto.choices || []).map(choice => JSON.parse(choice)),
description: {
pl: dto.description,
en: dto.descriptionEng,
},
label: {
pl: dto.label,
en: dto.labelEng,
},
type: fieldDefinitionDtoTypeTransformer.transform(dto.fieldType),
}
},
reverseTransform(subject: ReportFieldDefinition, context?: unknown): FieldDefinitionDTO {
return {
id: subject.id,
choices: "choices" in subject && subject.choices.map(choice => JSON.stringify(choice)) || [],
description: subject.description.pl,
descriptionEng: subject.description.en,
fieldType: fieldDefinitionDtoTypeTransformer.reverseTransform(subject.type),
label: subject.label.pl,
labelEng: subject.label.en,
}
}
}
export const editionTeaserDtoTransformer: OneWayTransformer<EditionTeaserDTO, Subset<Edition>> = {
transform(subject: EditionTeaserDTO, context?: undefined): Subset<Edition> {
return subject && {
@ -45,7 +126,9 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
editionStart: subject.startDate.toISOString(),
course: courseDtoTransformer.reverseTransform(subject.course),
reportingStart: subject.reportingStart.toISOString(),
availableSubjects: [],
availableSubjects: subject.program.map(entry => programEntryDtoTransformer.reverseTransform(entry)),
availableInternshipTypes: subject.types.map(entry => internshipTypeDtoTransformer.reverseTransform(entry)),
reportSchema: subject.schema.map(entry => fieldDefinitionDtoTransformer.reverseTransform(entry)),
};
},
transform(subject: EditionDTO, context: undefined): Edition {
@ -59,6 +142,9 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
proposalDeadline: moment(subject.reportingStart),
reportingStart: moment(subject.reportingStart),
reportingEnd: moment(subject.reportingStart).add(1, 'month'),
program: (subject.availableSubjects || []).map(entry => programEntryDtoTransformer.transform(entry)),
types: (subject.availableInternshipTypes || []).map(entry => internshipTypeDtoTransformer.transform(entry)),
schema: (subject.reportSchema || []).map(entry => fieldDefinitionDtoTransformer.transform(entry)),
};
}
}
@ -78,3 +164,28 @@ export const programEntryDtoTransformer: Transformer<ProgramEntryDTO, Internship
}
},
}
interface EditionUpdateDTO extends Identifiable {
editionStart: string;
editionFinish: string;
reportingStart: string;
course: CourseDTO;
availableSubjectsIds: Identifier[],
availableInternshipTypesIds: Identifier[],
reportSchema: Identifier[],
}
export const editionUpdateDtoTransformer: OneWayTransformer<Edition, EditionUpdateDTO> = {
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[],
}
}
}

View File

@ -1,5 +1,5 @@
import { Address, Company, Identifiable, Internship, Mentor, Office } from "@/data";
import { momentSerializationTransformer, OneWayTransformer } from "@/serialization";
import { Address, Company, Identifiable, Internship, Mentor, Office, Stateful } from "@/data";
import { momentSerializationTransformer, OneWayTransformer, Transformer } from "@/serialization";
import { Nullable } from "@/helpers";
import { MentorDTO, mentorDtoTransformer } from "@/api/dto/mentor";
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
@ -7,6 +7,14 @@ import { Moment } from "moment-timezone";
import { sampleStudent } from "@/provider/dummy";
import { UploadType } from "@/api/upload";
import { ProgramEntryDTO, programEntryDtoTransformer } from "@/api/dto/edition";
import { StudentDTO } from "@/api/dto/student";
import { SubmissionStatus } from "@/state/reducer/submission";
import { Report } from "@/data/report";
export interface StatefulDTO {
state: SubmissionState;
changeStateComment: string;
}
export enum SubmissionState {
Draft = "Draft",
@ -16,6 +24,50 @@ export enum SubmissionState {
Archival = "Archival",
}
export const submissionStateDtoTransformer: Transformer<SubmissionState, SubmissionStatus> = {
reverseTransform(subject: SubmissionStatus, context: undefined): SubmissionState {
switch (subject) {
case "draft":
return SubmissionState.Draft;
case "awaiting":
return SubmissionState.Submitted;
case "accepted":
return SubmissionState.Accepted;
case "declined":
return SubmissionState.Rejected;
}
},
transform(subject: SubmissionState, context: undefined): SubmissionStatus {
switch (subject) {
case SubmissionState.Draft:
return "draft";
case SubmissionState.Submitted:
return "awaiting";
case SubmissionState.Accepted:
return "accepted";
case SubmissionState.Rejected:
return "declined";
case SubmissionState.Archival:
return "declined";
}
}
}
export const statefulDtoTransformer: Transformer<StatefulDTO, Stateful> = {
reverseTransform(subject: Stateful, context: undefined): StatefulDTO {
return {
changeStateComment: subject.comment,
state: submissionStateDtoTransformer.reverseTransform(subject.state, context),
};
},
transform(subject: StatefulDTO, context: undefined): Stateful {
return {
comment: subject.changeStateComment,
state: submissionStateDtoTransformer.transform(subject.state),
}
}
}
export interface NewBranchOffice extends Address {
}
@ -40,29 +92,50 @@ export interface InternshipRegistrationUpdate {
subjects: string[],
}
export interface InternshipRegistrationDTO extends Identifiable {
export interface InternshipRegistrationDTO extends Identifiable, StatefulDTO {
start: string;
end: string;
type: InternshipTypeDTO,
state: SubmissionState,
mentor: MentorDTO,
company: Company,
branchAddress: Office,
declaredHours: number,
subjects: { subject: ProgramEntryDTO }[],
submissionDate: string,
}
export interface InternshipDocument extends Identifiable {
export interface InternshipDocument extends Identifiable, Stateful {
description: null,
type: UploadType,
state: SubmissionState,
}
export interface InternshipDocumentDTO extends Identifiable, StatefulDTO {
description: null;
type: UploadType;
}
export interface InternshipReportDTO extends StatefulDTO, Identifiable {
value: string;
}
const reference = (subject: Identifiable | null): Identifiable | null => subject && { id: subject.id };
export interface InternshipInfoDTO {
export interface InternshipInfoDTO extends Identifiable {
internshipRegistration: InternshipRegistrationDTO;
documentation: InternshipDocument[],
documentation: InternshipDocumentDTO[],
student: StudentDTO,
report: InternshipReportDTO,
grade: number,
}
export const internshipReportDtoTransformer: OneWayTransformer<InternshipReportDTO, Report> = {
transform(subject: InternshipReportDTO, context?: unknown): Report {
return {
id: subject.id,
fields: JSON.parse(subject.value),
...statefulDtoTransformer.transform(subject),
}
}
}
export const internshipRegistrationUpdateTransformer: OneWayTransformer<Nullable<Internship>, Nullable<InternshipRegistrationUpdate>> = {
@ -106,3 +179,12 @@ export const internshipRegistrationDtoTransformer: OneWayTransformer<InternshipR
};
}
}
export const internshipDocumentDtoTransformer: OneWayTransformer<InternshipDocumentDTO, InternshipDocument> = {
transform(dto: InternshipDocumentDTO, context?: unknown): InternshipDocument {
return {
...dto,
...statefulDtoTransformer.transform(dto),
}
}
}

View File

@ -11,6 +11,7 @@ import * as type from "./type";
import * as companies from "./companies";
import * as internship from "./internship";
import * as upload from "./upload";
import * as report from "./report";
export const axios = Axios.create({
baseURL: process.env.API_BASE_URL || `https://${window.location.hostname}/api/`,
@ -41,7 +42,8 @@ const api = {
type,
companies,
internship,
upload
upload,
report,
}
export default api;

8
src/api/report.ts Normal file
View File

@ -0,0 +1,8 @@
import { Report, ReportFieldValues } from "@/data/report";
import { axios } from "@/api/index";
const REPORT_SAVE_ENDPOINT = "/internship/report"
export async function save(report: Report) {
await axios.post(REPORT_SAVE_ENDPOINT, report.fields);
}

View File

@ -1,6 +1,6 @@
import React, { HTMLProps, useEffect } from 'react';
import { Link, Route, Switch } from "react-router-dom"
import { processMiddlewares, route, routes } from "@/routing";
import { processMiddlewares, route, Routes, routes } from "@/routing";
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { Trans, useTranslation } from "react-i18next";
@ -97,14 +97,7 @@ function App() {
</Container>
</header>
<main id="content">
{ <Switch>
{ routes.map(({ name, content, middlewares = [], ...route }) =>
<Route { ...route } key={ name } render={ () => {
const Next = () => processMiddlewares([ ...middlewares, content ])
return <Next />
} } />
) }
</Switch> }
<Routes routes={ routes.filter(route => !route.tags || route.tags.length == 0) }/>
</main>
<footer className="footer">
<Container style={{ display: 'flex', alignItems: "center" }}>

View File

@ -1,9 +1,82 @@
import React, { useState } from "react";
import { Button, ButtonGroup, Dialog, DialogActions, DialogContent, DialogTitle, Menu, MenuItem, TextField, Typography } from "@material-ui/core";
import {
Button,
ButtonGroup,
ButtonProps,
Dialog,
DialogActions,
DialogContent,
DialogProps,
DialogTitle, FormControl,
Menu,
MenuItem,
TextField,
Typography
} from "@material-ui/core";
import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui";
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;
label: string;
} & DialogProps;
export function AcceptSubmissionDialog({ onAccept, label, ...props }: AcceptSubmissionDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState<string>("");
const classes = useVerticalSpacing(3);
return <Dialog maxWidth="xl" { ...props }>
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
<DialogActions>
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>
{ t('cancel') }
</Button>
<Button onClick={ () => onAccept?.(comment) } color="primary" variant="contained">
{ t('confirm') }
</Button>
</DialogActions>
</DialogContent>
</Dialog>
}
type DiscardSubmissionDialogProps = {
onDiscard: (comment: string) => void;
label: string;
} & DialogProps;
export function DiscardSubmissionDialog({ onDiscard, label, ...props }: DiscardSubmissionDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState<string>("");
const classes = useVerticalSpacing(3);
return <Dialog maxWidth="xl" { ...props }>
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
<DialogActions>
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>
{ t('cancel') }
</Button>
<Button onClick={ () => onDiscard?.(comment) } color="primary" variant="contained">
{ t('confirm') }
</Button>
</DialogActions>
</DialogContent>
</Dialog>
}
type AcceptanceActionsProps = {
onAccept: (comment?: string) => void;
@ -17,19 +90,8 @@ export function AcceptanceActions({ onAccept, onDiscard, label }: AcceptanceActi
const [isDiscardModalOpen, setDiscardModelOpen] = useState<boolean>(false);
const [isAcceptModalOpen, setAcceptModelOpen] = useState<boolean>(false);
const [comment, setComment] = useState<string>("");
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const classes = useVerticalSpacing(3);
const handleAccept = () => {
onAccept(comment);
}
const handleDiscard = () => {
onDiscard(comment);
}
const handleAcceptModalClose = () => {
setAcceptModelOpen(false);
}
@ -77,39 +139,8 @@ export function AcceptanceActions({ onAccept, onDiscard, label }: AcceptanceActi
</Button>
{ createPortal(<>
<Dialog open={ isDiscardModalOpen } onClose={ handleDiscardModalClose } maxWidth="md">
<DialogTitle>{ t(label + ".discard.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t(label + ".discard.info") }</Typography>
<TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") } rows={3}/>
<DialogActions>
<Button onClick={ handleDiscardModalClose }>
{ t('cancel') }
</Button>
<Button onClick={ handleDiscard } color="primary" variant="contained">
{ t('confirm') }
</Button>
</DialogActions>
</DialogContent>
</Dialog>
<Dialog open={ isAcceptModalOpen } onClose={ handleAcceptModalClose } maxWidth="md">
<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}/>
<DialogActions>
<Button onClick={ handleAcceptModalClose }>
{ t('cancel') }
</Button>
<Button onClick={ handleAccept } color="primary" variant="contained">
{ t('confirm') }
</Button>
</DialogActions>
</DialogContent>
</Dialog>
<DiscardSubmissionDialog open={ isDiscardModalOpen } onClose={ handleDiscardModalClose } maxWidth="md" onDiscard={ onDiscard } label={ label }/>
<AcceptSubmissionDialog open={ isAcceptModalOpen } onClose={ handleAcceptModalClose } maxWidth="md" onAccept={ onAccept } label={ label }/>
</>, document.getElementById("modals") as Element) }
</>
}

View File

@ -1,6 +1,5 @@
import { AsyncResult } from "@/hooks";
import React from "react";
import { CircularProgress } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { Loading } from "@/components/loading";

View File

@ -75,7 +75,7 @@ export const FileInfo = ({ document, ...props }: FileInfoProps) => {
<Async async={ fileinfo }>
{ fileinfo => <div className={ classes.grid }>
<div className={ classes.iconColumn }>
<FileIcon mime={ fileinfo.mime } className={ classes.icon } />
<FileIcon mime={ fileinfo.mime || "" } className={ classes.icon } />
</div>
<aside className={ classes.asideColumn }>
<Typography variant="h5" className={ classes.header }>{ fileinfo.filename }</Typography>

View File

@ -24,7 +24,7 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
<StudentPreview student={ proposal.intern } />
</div>
<Section>
{ proposal.company && proposal.office && <Section>
<Label>{ t('internship.sections.place') }</Label>
<Typography className="proposal__primary">
{ proposal.company.name }
@ -36,12 +36,12 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
<Label>{ t('internship.office') }</Label>
<Typography className="proposal__primary">{ t('internship.address.city', proposal.office.address) }</Typography>
<Typography className="proposal__secondary">{ t('internship.address.street', proposal.office.address) }</Typography>
</Section>
</Section> }
<Section>
{ proposal.type && <Section>
<Label>{ t('internship.sections.kind') }</Label>
<Typography className="proposal__primary">{ proposal.type.label.pl }</Typography>
</Section>
</Section> }
<Section>
<Label>{ t('internship.sections.program') }</Label>

View File

@ -29,19 +29,18 @@ export const Step = (props: StepProps) => {
{ label }
<Box>
{ state && <Typography variant="subtitle2" display="inline">{ state }</Typography> }
{ (notBefore || until) && <Typography variant="subtitle2" display="inline" color="textSecondary"> </Typography> }
{ notBefore &&
<Typography variant="subtitle2" color="textSecondary" display="inline">
{ t('not-before', { date: notBefore }) }
</Typography> }
{ until && <>
<Typography variant="subtitle2" display="inline" color="textSecondary"> </Typography>
{ until &&
<Typography variant="subtitle2" color="textSecondary" display="inline">
{ t('until', { date: until }) }
{ isLate && <Typography color="error" display="inline"
variant="body2"> - { t('late', { by: moment.duration(now.diff(until)) }) }</Typography> }
{ !isLate && !completed && <Typography display="inline" variant="body2"> - { t('left', { left: left }) }</Typography> }
</Typography>
</> }
</Typography> }
</Box>
</StepLabel>
{ children && <StepContent>{ children }</StepContent> }

View File

@ -1,3 +1,5 @@
import { SubmissionStatus } from "@/state/reducer/submission";
export type Identifier = string;
export interface Identifiable {
@ -8,3 +10,7 @@ export type Multilingual<T> = {
pl: T,
en: T
}
export interface Stateful {
comment: string;
state: SubmissionStatus;
}

View File

@ -1,6 +1,8 @@
import { Moment } from "moment-timezone";
import { Course } from "@/data/course";
import { Identifiable } from "@/data/common";
import { InternshipProgramEntry, InternshipType } from "@/data/internship";
import { ReportSchema } from "@/data/report";
export type Edition = {
course: Course;
@ -11,6 +13,9 @@ export type Edition = {
reportingEnd: Moment,
minimumInternshipHours: number;
maximumInternshipHours?: number;
program: InternshipProgramEntry[];
types: InternshipType[];
schema: ReportSchema;
} & Identifiable
export type Deadlines = {

41
src/data/report.ts Normal file
View File

@ -0,0 +1,41 @@
import { Identifiable, Multilingual, Stateful } from "@/data/common";
interface PredefinedChoices {
choices: Multilingual<string>[];
}
export interface BaseFieldDefinition extends Identifiable {
description: Multilingual<string>;
label: Multilingual<string>;
}
export interface TextFieldDefinition extends BaseFieldDefinition {
type: "short-text" | "long-text";
}
export type TextFieldValue = string;
export interface MultiChoiceFieldDefinition extends BaseFieldDefinition, PredefinedChoices {
type: "checkbox";
}
export type MultiChoiceValue = Multilingual<string>[];
export interface SingleChoiceFieldDefinition extends BaseFieldDefinition, PredefinedChoices {
type: "radio" | "select";
}
export type SingleChoiceValue = Multilingual<string>;
export type ReportFieldDefinition = TextFieldDefinition | MultiChoiceFieldDefinition | SingleChoiceFieldDefinition;
export type ReportFieldValue = TextFieldValue | MultiChoiceValue | SingleChoiceValue;
export type ReportFieldValues = { [field: string]: ReportFieldValue };
export type ReportSchema = ReportFieldDefinition[];
export type ReportFieldType = ReportFieldDefinition['type'];
export interface Report extends Stateful, Identifiable {
fields: ReportFieldValues;
}
export const reportFieldTypes: ReportFieldType[] = ["short-text", "long-text", "checkbox", "radio", "select"];

View File

@ -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 }`;

152
src/forms/report.tsx Normal file
View File

@ -0,0 +1,152 @@
import React from "react";
import { emptyReport, sampleReportSchema } from "@/provider/dummy/report";
import {
Button,
FormControl,
FormLabel,
Grid,
Typography,
FormGroup,
FormControlLabel,
Checkbox,
Radio,
InputLabel,
Select,
MenuItem
} from "@material-ui/core";
import { Actions } from "@/components";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import { useTranslation } from "react-i18next";
import { MultiChoiceFieldDefinition, Report, ReportFieldDefinition, ReportFieldValues, SingleChoiceFieldDefinition } from "@/data/report";
import { TextField as TextFieldFormik } from "formik-material-ui";
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<TField = ReportFieldDefinition> = {
field: TField;
}
export const name = ({ id }: ReportFieldDefinition) => `field_${id}`;
export const CustomField = ({ field, ...props }: ReportFieldProps) => {
switch (field.type) {
case "short-text":
case "long-text":
return <CustomField.Text {...props} field={ field } />
case "checkbox":
case "radio":
return <CustomField.Choice {...props} field={ field }/>
case "select":
return <CustomField.Select {...props} field={ field }/>
}
}
CustomField.Text = ({ field }: ReportFieldProps) => {
return <>
<Field label={ field.label.pl } name={ name(field) }
fullWidth
rows={ field.type == "long-text" ? 4 : 1 }
multiline={ field.type == "long-text" }
component={ TextFieldFormik }
/>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</>
}
CustomField.Select = ({ field }: ReportFieldProps<SingleChoiceFieldDefinition>) => {
const { t } = useTranslation();
const id = `custom-field-${field.id}`;
const { values, setFieldValue } = useFormikContext<any>();
const value = values[name(field)];
return <FormControl variant="outlined">
<InputLabel htmlFor={id}>{ field.label.pl }</InputLabel>
<Select label={ field.label.pl } name={ name(field) } id={id} value={ value } onChange={ ({ target }) => setFieldValue(name(field), target.value, false) }>
{ field.choices.map(choice => <MenuItem value={ choice as any }>{ choice.pl }</MenuItem>) }
</Select>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</FormControl>
}
CustomField.Choice = ({ field }: ReportFieldProps<SingleChoiceFieldDefinition | MultiChoiceFieldDefinition>) => {
const { t } = useTranslation();
const { values, setFieldValue } = useFormikContext<any>();
const value = values[name(field)];
const isSelected = field.type == 'radio'
? (checked: Multilingual<string>) => value == checked
: (checked: Multilingual<string>) => (value || []).includes(checked)
const handleChange = field.type == 'radio'
? (choice: Multilingual<string>) => () => setFieldValue(name(field), choice, false)
: (choice: Multilingual<string>) => () => {
const current = value || [];
setFieldValue(name(field), !current.includes(choice) ? [ ...current, choice ] : current.filter((c: Multilingual<string>) => c != choice), false);
}
const Component = field.type == 'radio' ? Radio : Checkbox;
return <FormControl component="fieldset">
<FormLabel component="legend">{ field.label.pl }</FormLabel>
<FormGroup>
{ field.choices.map(choice => <FormControlLabel
control={ <Component checked={ isSelected(choice) } onChange={ handleChange(choice) }/> }
label={ choice.pl }
/>) }
</FormGroup>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</FormControl>
}
export type ReportFormValues = ReportFieldValues;
const reportFormValuesTransformer: Transformer<Report, ReportFormValues, { report: Report }> = {
reverseTransform(subject: ReportFormValues, context: { report: Report }): Report {
return { ...context.report, fields: subject };
},
transform(subject: Report, context: undefined): ReportFormValues {
return subject.fields;
}
}
export default function ReportForm() {
const edition = useCurrentEdition() as Edition;
const report = emptyReport;
const schema = edition.schema;
const { t } = useTranslation();
const handleSubmit = async (values: ReportFormValues) => {
const result = reportFormValuesTransformer.reverseTransform(values, { report });
await api.report.save(result);
};
return <Formik initialValues={ reportFormValuesTransformer.transform(report) } onSubmit={ handleSubmit }>
{ ({ submitForm }) => <Form>
<Grid container>
<Grid item xs={12}>
<Typography variant="body1" component="p">{ t('forms.report.instructions') }</Typography>
</Grid>
{ schema.map(field => <Grid item xs={12}><CustomField field={ field }/></Grid>) }
<Grid item xs={12}>
<Actions>
<Button variant="contained" color="primary" onClick={ submitForm }>
{ t('confirm') }
</Button>
<Button component={ RouterLink } to={ route("home") }>
{ t('go-back') }
</Button>
</Actions>
</Grid>
</Grid>
</Form> }
</Formik>
}

View File

@ -4,8 +4,9 @@ import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
import "moment/locale/pl"
import "moment/locale/en-gb"
import moment, { isDuration, isMoment, unitOfTime } from "moment-timezone";
import moment, { isDuration, isMoment, Moment, unitOfTime } from "moment-timezone";
import { convertToRoman } from "@/utils/numbers";
import MomentUtils from "@date-io/moment";
const resources = {
en: {
@ -52,4 +53,10 @@ i18n
document.documentElement.lang = i18n.language;
moment.locale(i18n.language)
export class LocalizedMomentUtils extends MomentUtils {
getDatePickerHeaderText(date: Moment): string {
return this.format(date, "d MMM yyyy");
}
}
export default i18n;

View File

@ -10,13 +10,7 @@ import { MuiPickersUtilsProvider } from "@material-ui/pickers";
import moment, { Moment } from "moment-timezone";
import { studentTheme } from "@/ui/theme";
import { BrowserRouter } from "react-router-dom";
import MomentUtils from "@date-io/moment";
class LocalizedMomentUtils extends MomentUtils {
getDatePickerHeaderText(date: Moment): string {
return this.format(date, "d MMM yyyy");
}
}
import { LocalizedMomentUtils } from "@/i18n";
ReactDOM.render(
<React.StrictMode>

View File

@ -0,0 +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<Course[]> {
const response = await axios.get<Course[]>(COURSE_INDEX_ENDPOINT);
return response.data;
}

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/:id/accept";
const DOCUMENT_REJECT_ENDPOINT = "/management/document/:id/reject";
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

@ -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<Edition> {
const response = await axios.get<EditionDTO>(prepare(MANAGEMENT_EDITION_ENDPOINT, { edition }));
return editionDtoTransformer.transform(response.data);
}
export async function save(edition: Edition): Promise<boolean> {
const response = await axios.put<EditionDTO>(
MANAGEMENT_EDITION_INDEX_ENDPOINT,
editionUpdateDtoTransformer.transform(edition),
);
return response.status == 200;
}

View File

@ -0,0 +1,14 @@
import { ReportFieldDefinition } from "@/data/report";
import { axios } from "@/api";
import { FieldDefinitionDTO, fieldDefinitionDtoTransformer } from "@/api/dto/edition";
const REPORT_FIELD_INDEX_ENDPOINT = "/management/report/fields"
export async function all(): Promise<ReportFieldDefinition[]> {
const result = await axios.get<FieldDefinitionDTO[]>(REPORT_FIELD_INDEX_ENDPOINT);
return (result.data || []).map(field => fieldDefinitionDtoTransformer.transform(field));
}
export async function save(field: ReportFieldDefinition) {
await axios.post(REPORT_FIELD_INDEX_ENDPOINT, fieldDefinitionDtoTransformer.reverseTransform(field));
}

View File

@ -1,11 +1,21 @@
import * as edition from "./edition"
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"
import * as field from "./field"
import * as report from "./report"
export const api = {
edition,
page,
type
type,
course,
internship,
document,
field,
report
}
export default api;

View File

@ -0,0 +1,109 @@
import { Identifiable, Identifier, Internship } from "@/data";
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 {
InternshipDocument,
InternshipDocumentDTO,
internshipDocumentDtoTransformer,
InternshipInfoDTO, internshipReportDtoTransformer,
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";
import { Report } from "@/data/report";
export type InternshipSubmission = Nullable<Internship> & {
state: SubmissionStatus,
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";
const internshipInfoDtoTransformer: Transformer<InternshipInfoDTO, InternshipSubmission> = {
transform(subject: InternshipInfoDTO, context?: never): InternshipSubmission {
// @ts-ignore
const ipp = subject.documentation.find<InternshipDocumentDTO>(doc => doc.type === UploadType.Ipp);
const report = subject.report;
return {
...subject,
changed: moment(subject.internshipRegistration.submissionDate),
company: subject.internshipRegistration.company,
startDate: moment(subject.internshipRegistration.start),
endDate: moment(subject.internshipRegistration.end),
hours: subject.internshipRegistration.declaredHours,
id: subject.id,
intern: subject.student && studentDtoTransfer.transform(subject.student),
isAccepted: false,
lengthInWeeks: 0,
mentor: subject.internshipRegistration.mentor && mentorDtoTransformer.transform(subject.internshipRegistration.mentor),
office: subject.internshipRegistration.branchAddress,
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),
report: report && internshipReportDtoTransformer.transform(report),
approvals: (subject.documentation.filter(doc => doc.type === UploadType.DeanConsent).map(subject => internshipDocumentDtoTransformer.transform(subject)))
};
},
reverseTransform(subject: InternshipSubmission, context: undefined): InternshipInfoDTO {
return {} as any;
},
}
export async function all(edition: Identifiable): Promise<InternshipSubmission[]> {
const result = await axios.get<InternshipInfoDTO[]>(query(INTERNSHIP_MANAGEMENT_INDEX_ENDPOINT, { EditionId: edition.id || "" }));
return result.data.map(result => internshipInfoDtoTransformer.transform(result))
}
export async function get(id: Identifier): Promise<InternshipSubmission> {
const result = await axios.get<InternshipInfoDTO>(prepare(INTERNSHIP_MANAGEMENT_ENDPOINT, { id }))
return internshipInfoDtoTransformer.transform(result.data);
}
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 || ""}),
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 || ""}),
JSON.stringify(comment),
{ headers: { 'Content-Type': 'application/json' } }
)))
}
export async function grade(internship: OneOrMany<Internship>, grade: number): Promise<void> {
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' } }
)))
}

View File

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

View File

@ -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<HTMLDivElement>;
export const StepState = ({ label, state, icon, ...props }: StepStateProps) => {
const { t } = useTranslation();
const classes = useStyles();
return <Tooltip title={`${label} - ${t(`submission.status.${state || "empty"}`)}`}>
<div className={ classNames(classes.root, state && classes[state]) } { ...props }>
{ icon }
<div className={ classes.icon }>
{ state ? stateIcons[state] : <Close /> }
</div>
</div>
</Tooltip>
}

View File

@ -0,0 +1,185 @@
import React, { useCallback } from "react";
import { Edition } from "@/data/edition";
import { Nullable } from "@/helpers";
import { useTranslation } from "react-i18next";
import { FieldProps, Field, FieldArrayRenderProps, FieldArray, getIn } from "formik";
import { identityTransformer, Transformer } from "@/serialization";
import {
Button, Card, CardContent, CardHeader,
Checkbox,
Grid, IconButton,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
Paper,
TextField,
Tooltip,
Typography
} from "@material-ui/core";
import { useSpacing } from "@/styles";
import { Moment } from "moment-timezone";
import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers";
import { TextField as TextFieldFormik } from "formik-material-ui";
import { Course, Identifiable, InternshipProgramEntry, InternshipType } from "@/data";
import { Autocomplete } from "@material-ui/lab";
import { useAsync } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import { AccountCheck, ArrowDown, ArrowUp, ShieldCheck, TrashCan } from "mdi-material-ui";
import { Actions } from "@/components";
import { Add } from "@material-ui/icons";
export type EditionFormValues = Omit<Nullable<Edition>, "schema">;
export const initialEditionFormValues: EditionFormValues = {
course: null,
endDate: null,
minimumInternshipHours: 80,
proposalDeadline: null,
reportingEnd: null,
reportingStart: null,
startDate: null,
types: [],
program: [],
}
export const editionFormValuesTransformer: Transformer<Edition, EditionFormValues> = identityTransformer;
export function toggleValueInArray<T extends Identifiable>(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));
}
export const ProgramField = ({ remove, swap, push, form, name, ...props }: FieldArrayRenderProps) => {
const value = getIn(form.values, name) as InternshipProgramEntry[];
const { t } = useTranslation("management");
return <>
{ value.map((entry, index) => <Card>
<CardHeader
subheader={ t('edition.program.entry', { index: index + 1 }) }
action={ <>
{ index < value.length - 1 && <IconButton onClick={ () => swap(index, index + 1) }><ArrowDown /></IconButton> }
{ index > 0 && <IconButton onClick={ () => swap(index, index - 1) }><ArrowUp /></IconButton> }
<IconButton onClick={ () => remove(index) }><TrashCan /></IconButton>
</> }
/>
<CardContent>
<Field component={ TextFieldFormik }
label={ t('edition.program.field.description') }
name={`${name}[${index}].description`}
fullWidth
/>
</CardContent>
</Card>) }
<Actions>
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => push({ description: "" }) }>{ t("actions.add") }</Button>
</Actions>
</>
}
export const TypesField = ({ field, form, meta, ...props }: FieldProps<InternshipType[]>) => {
const { name, value = [] } = field;
const types = useAsync(useCallback(() => api.type.all(), []));
const { t } = useTranslation("management");
const toggle = (type: InternshipType) => () => form.setFieldValue(name, toggleValueInArray(value, type, (a, b) => a.id == b.id));
const isChecked = (type: InternshipType) => value.findIndex(v => v.id == type.id) !== -1;
return <Async async={ types }>
{ types => <List>{
types.map(type => <ListItem dense button onClick={ toggle(type) }>
<ListItemIcon>
<Checkbox edge="start" onChange={ toggle(type) } checked={ isChecked(type) }/>
</ListItemIcon>
<ListItemText>
<div>{ type.label.pl }</div>
<Typography variant="caption">{ type.description?.pl }</Typography>
</ListItemText>
<ListItemSecondaryAction>
<div style={{ display: "flex", flexDirection: "column" }}>
{ type.requiresDeanApproval && <Tooltip title={ t("type.flag.dean-approval") as string }><AccountCheck/></Tooltip> }
{ type.requiresInsurance && <Tooltip title={ t("type.flag.insurance") as string }><ShieldCheck/></Tooltip> }
</div>
</ListItemSecondaryAction>
</ListItem>)
}</List> }
</Async>
}
export const CoursePickerField = ({ field, form, meta, ...props }: FieldProps<Course>) => {
const courses = useAsync(useCallback(() => api.course.all(), []));
const { t } = useTranslation("management");
return <Autocomplete
options={ courses.isLoading ? [] : courses.value as Course[] }
renderInput={ props => <TextField { ...props } label={ t("edition.field.course") } fullWidth/> }
getOptionLabel={ course => course.name }
value={ field.value }
onChange={ (_, value) => form.setFieldValue(field.name, value, false) }
onBlur={ field.onBlur }
/>
}
export const DatePickerField = ({ field, form, meta, ...props }: FieldProps<Moment>) => {
const { value, onChange, onBlur } = field;
return <DatePicker value={ value }
onChange={ onChange }
onBlur={ onBlur }
{ ...props }
format="DD.MM.yyyy"
disableToolbar fullWidth
variant="inline"
/>
}
export const EditionForm = () => {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
return <div className={ spacing.vertical }>
<Typography variant="h5">{ t("edition.fields.basic") }</Typography>
<Grid container>
<Grid item xs={ 12 } md={ 6 }>
<Field name="startDate" component={ DatePickerField } label={ t("edition.field.start") } />
</Grid>
<Grid item xs={ 12 } md={ 6 }>
<Field name="endDate" component={ DatePickerField } label={ t("edition.field.end") } />
</Grid>
<Grid item xs={ 12 } md={ 6 }>
<Field name="course" component={ CoursePickerField } label={ t("edition.field.course") } />
</Grid>
<Grid item xs={ 12 } md={ 6 }>
</Grid>
<Grid item xs={ 12 } md={ 6 }>
<Field name="minimumInternshipHours" component={ TextFieldFormik } label={ t("edition.field.minimumInternshipHours") } />
</Grid>
</Grid>
<Typography variant="h5">{ t("edition.fields.deadlines") }</Typography>
<Grid container>
<Grid item xs={ 12 } md={ 6 }>
<Field name="proposalDeadline" component={ DatePickerField } label={ t("edition.field.proposalDeadline") } />
</Grid>
<Grid item xs={ 12 } md={ 6 }>
</Grid>
<Grid item xs={ 12 } md={ 6 }>
<Field name="reportingStart" component={ DatePickerField } label={ t("edition.field.reportingStart") } />
</Grid>
<Grid item xs={ 12 } md={ 6 }>
<Field name="reportingEnd" component={ DatePickerField } label={ t("edition.field.reportingEnd") } />
</Grid>
</Grid>
<Typography variant="h5">{ t("edition.fields.program") }</Typography>
<FieldArray name="program" component={ ProgramField as any } />
<Typography variant="h5">{ t("edition.fields.types") }</Typography>
<Paper elevation={ 2 }>
<Field name="types" component={ TypesField } />
</Paper>
</div>
}

View File

@ -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<DialogProps, "onSubmit">;
export const GradeDialog = ({ internship, onSubmit, ...props }: GradeDialogProps) => {
const [grade, setGrade] = useState<number | null>(internship.grade || null);
const { t } = useTranslation("management");
const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
setGrade(event.target.value as number);
};
return <Dialog maxWidth="sm" fullWidth { ...props }>
<DialogTitle>{ t("internship.grade") }</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">{ t("internship.grade") }</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={ grade }
onChange={ handleChange }
>
<MenuItem value={ 2.0 }>2 - Niedostateczny</MenuItem>
<MenuItem value={ 3.0 }>3 - Dostateczny</MenuItem>
<MenuItem value={ 3.5 }>3.5 - Dostateczny plus</MenuItem>
<MenuItem value={ 4.0 }>4 - Dobry</MenuItem>
<MenuItem value={ 4.5 }>4.5 - Dobry plus</MenuItem>
<MenuItem value={ 5.0 }>5 - Bardzo Dobry</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={ () => onSubmit(grade as number) } disabled={ grade === null }>{ t("save") }</Button>
</DialogActions>
</Dialog>
}

View File

@ -0,0 +1,240 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncState } from "@/hooks";
import { useSpacing } from "@/styles";
import api from "@/management/api";
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 {
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 { InternshipSubmission } from "@/management/api/internship";
import { createPortal } from "react-dom";
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 <>
<div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children || <FileFind /> }</div>
{ createPortal(
<InternshipDetailsDialog onDiscard={ handleDiscard } onAccept={ handleAccept } internship={ internship } open={ open } onClose={ () => 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
? <div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
: children }
{ createPortal(
<Dialog maxWidth="md" fullWidth open={ open } onClose={ () => setOpen(false) }>
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
<DialogContent>
{ internship.ipp && <FileInfo document={ internship.ipp } /> }
</DialogContent>
<DialogActions>
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="internship"/>
</DialogActions>
</Dialog>,
document.getElementById("modals") as Element,
) }
</>;
}
const StudentAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return <>
<div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
{ createPortal(
<Dialog maxWidth="md" fullWidth open={ open } onClose={ () => setOpen(false) }>
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
<DialogContent>
{ internship.intern && <StudentPreview student={ internship.intern } /> }
</DialogContent>
</Dialog>,
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<SubmissionStatus | null>((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 <div className={ spacing.horizontal } style={{ display: "flex" }}>
<StudentAction internship={ internship }>
<StepState state={ studentDataState } label={ t("steps.personal-data.header") } icon={ <Account /> } />
</StudentAction>
<ProposalAction internship={ internship }>
<StepState state={ proposalState } label={ t("steps.internship-proposal.header") } icon={ <BriefcaseAccount /> } />
</ProposalAction>
<IPPAction internship={ internship }>
<StepState state={ ippState } label={ t("steps.plan.header") } icon={ <FormatPageBreak /> } />
</IPPAction>
<StepState state={ reportState } label={ t("steps.report.header") } icon={ <FileChartOutline /> } />
<StepState state={ gradeState } label={ t("steps.grade.header") } icon={ <Star /> } />
<StepState state={ approvalState } label={ t("steps.approvals.header") } icon={ <CertificateOutline/> }
style={ approvalState ? {} : { opacity: 0.2 } }
/>
</div>
}
export const InternshipManagement = ({ 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 GradeAction = ({ internship }: { internship: InternshipSubmission }) => {
const [open, setOpen] = useState(false);
const handleGradeSubmission = async (grade: number) => {
await api.internship.grade(internship as Internship, grade);
setOpen(false);
updateInternshipList();
}
return <>
<Tooltip title={ t("internship.grade") as string }>
<IconButton onClick={ () => setOpen(true) }><StickerCheckOutline/></IconButton>
</Tooltip>
{ createPortal(
<GradeDialog onSubmit={ handleGradeSubmission } internship={ internship } open={ open } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element,
) }
</>;
}
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.status"),
render: summary => <InternshipState internship={ summary } />
},
{
title: t("internship.column.grade"),
field: "grade",
width: 0,
},
actionsColumn(internship => <>
{ canGrade(internship) && <GradeAction internship={ internship } /> }
</>)
];
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 } }
/>
}</Async>
</Container>
</Page>
}

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/proposal/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 || null } />
},
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

@ -1,23 +1,24 @@
import React, { useCallback, useEffect } from "react";
import React, { useCallback } from "react";
import { Page } from "@/pages/base";
import { useTranslation } from "react-i18next";
import { useAsync, useAsyncState } from "@/hooks";
import { useAsync } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import { Container, Typography } from "@material-ui/core";
import MaterialTable, { Action, Column } from "material-table";
import { Container, IconButton, Tooltip, Typography } from "@material-ui/core";
import MaterialTable, { Column } from "material-table";
import { Edition } from "@/data/edition";
import { Pencil } from "mdi-material-ui";
import { FileFind } from "mdi-material-ui";
import { Management } from "../main";
import { createPortal } from "react-dom";
import { EditStaticPageDialog } from "@/management/page/create";
import { useHistory } from "react-router-dom";
import { route } from "@/routing";
import { actionsColumn } from "@/management/common/helpers";
export type EditionDetailsProps = {
edition: string;
}
export function EditionDetails({ edition, ...props }: EditionDetailsProps) {
const result = useAsync(useCallback(() => api.edition.details(edition), [ edition ]));
const result = useAsync(useCallback(() => api.edition.details(edition), [edition]));
return <Async async={ result }>{ edition => <pre>{ JSON.stringify(edition, null, 2) }</pre> }</Async>
}
@ -26,6 +27,15 @@ export function EditionsManagement() {
const { t } = useTranslation("management");
const editions = useAsync(useCallback(api.edition.all, []));
const ManageEditionAction = ({ edition }: { edition: Edition }) => {
const history = useHistory();
const handlePagePreview = async () => history.push(route('management:edition_manage', { edition: edition.id || "" }));
return <Tooltip title={ t("actions.manage") as string }>
<IconButton onClick={ handlePagePreview }><FileFind/></IconButton>
</Tooltip>;
}
const columns: Column<Edition>[] = [
{
title: t("edition.field.id"),
@ -47,13 +57,9 @@ export function EditionsManagement() {
customSort: (a, b) => a.course.name.localeCompare(b.course.name),
render: edition => edition.course.name,
},
]
const actions: Action<Edition>[] = [
{
icon: () => <Pencil />,
onClick: () => {},
}
actionsColumn(edition => <>
<ManageEditionAction edition={ edition }/>
</>),
]
return <Page>
@ -69,10 +75,9 @@ export function EditionsManagement() {
<MaterialTable
columns={ columns }
data={ editions }
actions={ actions }
detailPanel={ edition => <EditionDetails edition={ edition.id as string } /> }
detailPanel={ edition => <EditionDetails edition={ edition.id as string }/> }
title={ t("edition.index.title") }
options={{ search: false, actionsColumnIndex: -1 }}
options={ { search: false } }
/>
}
</Async>

View File

@ -0,0 +1,116 @@
import React, { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch, Link as RouterLink } from "react-router-dom";
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,
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";
import { useAsync } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import { OneOrMany } from "@/helpers";
const useSectionStyles = makeStyles((theme: Theme) => createStyles({
header: {
padding: theme.spacing(2),
paddingBottom: 0,
fontWeight: 'bold',
color: theme.palette.grey["800"],
},
}))
export function title(edition: Edition) {
return `${ edition.course.name } - ${ edition.startDate.year() }`
}
export const EditionContext = React.createContext<Edition | null>(null);
export const EditionManagement = ({ edition }: EditionManagementProps) => {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
const classes = useSectionStyles();
return <Page>
<Page.Header>
<Management.Breadcrumbs>
<Typography color="textPrimary">{ title(edition) }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ title(edition) }</Page.Title>
</Page.Header>
<Container className={ spacing.vertical }>
<Paper elevation={ 2 }>
<Typography className={ classes.header }>{ t("edition.manage.internships") }</Typography>
<Management.Menu>
<ManagementLink icon={ <BriefcaseAccount/> } route={ route("management:edition_internships", { edition: edition.id || "" }) }>
{ t("management:edition.internships.title") }
</ManagementLink>
<ManagementLink icon={ <FileQuestionOutline/> } route={ route("management:edition_proposals", { edition: edition.id || "" }) }>
{ t("management:edition.proposals.title") }
</ManagementLink>
<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_reports", { edition: edition.id || "" }) }>
{ t("management:edition.reports.title") }
</ManagementLink>
<ManagementLink icon={ <CertificateOutline/> } route={ route("management:edition_report_form", { edition: edition.id || "" }) }>
{ t("management:edition.dean-approvals.title") }
</ManagementLink>
</Management.Menu>
</Paper>
<Paper elevation={ 2 }>
<Typography className={ classes.header }>{ t("edition.manage.management") }</Typography>
<Management.Menu>
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:edition_schema", { edition: edition.id || "" }) }>
{ t("management:edition.settings.schema") }
</ManagementLink>
<ManagementLink icon={ <CogOutline/> } route={ route("management:edition_settings", { edition: edition.id || "" }) }>
{ t("management:edition.settings.title") }
</ManagementLink>
</Management.Menu>
</Paper>
</Container>
</Page>
}
EditionManagement.Breadcrumbs = ({ children }: { children: OneOrMany<React.ReactChild> }) => {
const edition = useContext<Edition | null>(EditionContext);
return <Management.Breadcrumbs>
{ edition && (children
? <Link to={ route("management:edition_manage", { edition: edition.id || "" }) } component={ RouterLink }>{ title(edition) }</Link>
: <Typography color="textPrimary">{ title(edition) }</Typography>
) }
{ children }
</Management.Breadcrumbs>
}
export type EditionManagementProps = {
edition: Edition;
}
export const EditionRouter = () => {
const { params } = useRouteMatch();
const edition = useAsync<Edition>(useCallback(() => api.edition.details(params.edition), [params.edition]));
return <Async async={ edition }>{
result => <EditionContext.Provider value={ result }>
<Routes routes={ routes.filter(route => (route.tags || []).includes("edition")) } edition={ result }/>
</EditionContext.Provider>
}</Async>
}

View File

@ -0,0 +1,53 @@
import { SubmissionStatus } from "@/state/reducer/submission";
import React from "react";
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";
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<SubmissionStatus, {}>({
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 StateLabelProps = {
state: SubmissionStatus | null;
};
export const isValidState = (state: string | null) => ["accepted", "draft", "awaiting", "declined"].includes(state as string)
export const stateIcons: { [sate in SubmissionStatus]: React.ReactElement } = {
accepted: <NotebookCheckOutline/>,
awaiting: <HourglassEmptyRounded/>,
declined: <NotebookRemoveOutline/>,
draft: <NotebookEditOutline/>
}
export const StateLabel = ({ state }: StateLabelProps) => {
const classes = useStateLabelStyles();
const { t } = useTranslation();
return isValidState(state)
? <Chip icon={ stateIcons[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";
export const canAccept = (internship: InternshipSubmission) => ["declined", "awaiting"].includes(internship.state);
export const canDiscard = (internship: InternshipSubmission) => ["accepted", "awaiting"].includes(internship.state);

View File

@ -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 <Dialog maxWidth="lg" fullWidth { ...props }>
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
<DialogContent>
<Async async={details}>{ internship => <ProposalPreview proposal={ internship as Internship }/> }</Async>
</DialogContent>
<DialogActions>
<AcceptanceActions onAccept={ onAccept } onDiscard={ onDiscard } label="internship"/>
</DialogActions>
</Dialog>
}

View File

@ -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<InternshipSubmission[]>();
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
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 <>
<Tooltip title={ t("translation:accept") as any }><IconButton onClick={ () => setOpen(true) }><StickerCheckOutline /></IconButton></Tooltip>
{ createPortal(
<AcceptSubmissionDialog onAccept={ handleAccept } label="internship" open={ open } onClose={ () => 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 <>
<Tooltip title={ t("translation:discard") as any }><IconButton onClick={ () => setOpen(true) }><StickerRemoveOutline /></IconButton></Tooltip>
{ createPortal(
<DiscardSubmissionDialog onDiscard={ handleDiscard } label="internship" open={ open } onClose={ () => 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 <>
<Tooltip title={ t("translation:preview") as any }><IconButton onClick={ () => setOpen(true) }><FileFind /></IconButton></Tooltip>
{ createPortal(
<InternshipDetailsDialog onDiscard={ handleDiscard } onAccept={ handleAccept } internship={ internship } open={ open } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element,
) }
</>;
}
const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => {
const internship = useAsync(useCallback(() => api.internship.get(summary.id || ""), [ summary.id ]))
return <Box m={ 3 }><Async async={ internship }>{ internship => <ProposalPreview proposal={ internship as Internship } /> }</Async> </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.state } />
},
actionsColumn(internship => <>
{ canAccept(internship) && <AcceptAction internship={ internship } /> }
{ canDiscard(internship) && <DiscardAction internship={ internship } /> }
<PreviewAction internship={ internship } />
</>)
];
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

@ -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<ReportFieldDefinition[]>(useCallback(() => api.field.all(), []))
const [selected, setSelected] = useState<ReportFieldDefinition[]>(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 <Page>
<Page.Header maxWidth="md">
<EditionManagement.Breadcrumbs>
<Typography color="textPrimary">{ t(title) }</Typography>
</EditionManagement.Breadcrumbs>
<Page.Title>{ t(title) }</Page.Title>
</Page.Header>
<Container maxWidth="md" className={ spacing.vertical }>
<Async async={ fields }>
{ fields => <>
{ fields.map(field => <div style={{ display: "flex", alignItems: "start" }}>
<Checkbox onClick={ handleCheckboxClick(field) } checked={ isSelected(field) }/>
<Card style={{ flex: "1 1 auto" }}>
<CardHeader subheader={ field.label.pl } />
<CardContent><FieldPreview field={ field }/></CardContent>
</Card>
</div>) }
<Actions>
<Button variant="contained" color="primary" onClick={ handleSave }>{ t("save") }</Button>
</Actions>
</> }
</Async>
</Container>
</Page>;
}

View File

@ -0,0 +1,135 @@
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 { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action";
import { InternshipSubmission } from "@/management/api/internship";
import { StateLabel } from "@/management/edition/proposal/common";
import { createPortal } from "react-dom";
import { Stateful } from "@/data";
import { Report } from "@/data/report";
const title = "edition.reports.title";
export const canAccept = (subject: Stateful | null) => !!(subject && ["declined", "awaiting"].includes(subject.state));
export const canDiscard = (subject: Stateful | null) => !!(subject && ["accepted", "awaiting"].includes(subject.state));
export const ReportManagement = ({ 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.report.accept(internship.report as Report, 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.report.discard(internship.report as Report, 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.report && JSON.stringify(summary.report) }
</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.report?.state || null } />
},
actionsColumn(internship => <>
{ canAccept(internship.report) && <AcceptAction internship={ internship } /> }
{ canDiscard(internship.report) && <DiscardAction internship={ internship } /> }
</>)
];
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

@ -0,0 +1,63 @@
import React, { useCallback } from "react";
import { Page } from "@/pages/base";
import { Container, Divider, Typography, Button } from "@material-ui/core";
import { Async } from "@/components/async";
import { useTranslation } from "react-i18next";
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, EditionFormValues, editionFormValuesTransformer } from "@/management/edition/form";
import { Actions } from "@/components";
import { Save } from "@material-ui/icons";
import { Cancel } from "mdi-material-ui";
import { EditionManagement } from "./manage";
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<Edition>(useCallback(() => api.edition.details(params.edition), [params.edition]))
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 <Page>
<Page.Header maxWidth="md">
<EditionManagement.Breadcrumbs>
<Typography color="textPrimary">{ t(title) }</Typography>
</EditionManagement.Breadcrumbs>
<Page.Title>{ t(title) }</Page.Title>
</Page.Header>
<Container maxWidth="md">
<Async async={ edition }>
{ edition =>
<Formik initialValues={ edition } onSubmit={ handleSubmit }>
<Form className={ spacing.vertical }>
<EditionForm />
<Divider />
<Actions>
<Button variant="contained" color="primary" type="submit" startIcon={ <Save /> }>{ t("save") }</Button>
<Button startIcon={ <Cancel /> }>{ t("cancel") }</Button>
</Actions>
</Form>
</Formik>
}
</Async>
</Container>
</Page>;
}

View File

@ -4,7 +4,13 @@ import React from "react";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import { useTranslation } from "react-i18next";
import { CalendarClock, FileCertificateOutline, FileDocumentMultipleOutline } from "mdi-material-ui";
import { CalendarClock, FileCertificateOutline, FileDocumentMultipleOutline, FormatPageBreak } from "mdi-material-ui";
export const ManagementLink = ({ icon, route, children }: ManagementLinkProps) =>
<ListItem button component={ RouterLink } to={ route }>
<ListItemIcon>{ icon }</ListItemIcon>
<ListItemText>{ children }</ListItemText>
</ListItem>
export const Management = {
Breadcrumbs: ({ children, ...props }: BreadcrumbsProps) => {
@ -14,7 +20,9 @@ export const Management = {
<Link component={ RouterLink } to={ route("management:index") }>{ t("management:title") }</Link>
{ children }
</Page.Breadcrumbs>;
}
},
Menu: List,
MenuItem: ManagementLink,
}
type ManagementLinkProps = React.PropsWithChildren<{
@ -22,12 +30,6 @@ type ManagementLinkProps = React.PropsWithChildren<{
route: string,
}>;
const ManagementLink = ({ icon, route, children }: ManagementLinkProps) =>
<ListItem button component={ RouterLink } to={ route }>
<ListItemIcon>{ icon }</ListItemIcon>
<ListItemText>{ children }</ListItemText>
</ListItem>
export const ManagementIndex = () => {
const { t } = useTranslation();
@ -37,17 +39,20 @@ export const ManagementIndex = () => {
</Page.Header>
<Container>
<Paper elevation={ 2 }>
<List>
<Management.Menu>
<ManagementLink icon={ <CalendarClock /> } route={ route("management:editions") }>
{ t("management:edition.index.title") }
</ManagementLink>
<ManagementLink icon={ <FileCertificateOutline /> } route={ route("management:types") }>
{ t("management:type.index.title") }
</ManagementLink>
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:report_fields") }>
{ t("management:report-fields.title") }
</ManagementLink>
<ManagementLink icon={ <FileDocumentMultipleOutline /> } route={ route("management:static_pages") }>
{ t("management:page.index.title") }
</ManagementLink>
</List>
</Management.Menu>
</Paper>
</Container>
</Page>

View File

@ -0,0 +1,45 @@
import React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
import { ReportFieldDefinition } from "@/data/report";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
import { Form, Formik } from "formik";
import { Actions } from "@/components";
import { Save } from "@material-ui/icons";
import { Cancel } from "mdi-material-ui";
import { FieldDefinitionForm, FieldDefinitionFormValues, fieldFormValuesTransformer, initialFieldFormValues } from "@/management/report/fields/form";
export type EditFieldDialogProps = {
onSave?: (field: ReportFieldDefinition) => void;
field?: ReportFieldDefinition;
} & DialogProps;
export function EditFieldDefinitionDialog({ onSave, field, ...props }: EditFieldDialogProps) {
const { t } = useTranslation("management");
const spacing = useSpacing(3);
const handleSubmit = (values: FieldDefinitionFormValues) => {
onSave?.(fieldFormValuesTransformer.reverseTransform(values));
};
const initialValues = field
? fieldFormValuesTransformer.transform(field)
: initialFieldFormValues;
return <Dialog { ...props } maxWidth="md">
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
<Form className={ spacing.vertical }>
<DialogTitle>{ t(field ? "report-field.edit.title" : "report-field.create.title") }</DialogTitle>
<DialogContent>
<FieldDefinitionForm />
</DialogContent>
<DialogActions>
<Actions>
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
</Actions>
</DialogActions>
</Form>
</Formik>
</Dialog>
}

View File

@ -0,0 +1,102 @@
import React from "react";
import { ReportFieldDefinition, reportFieldTypes } from "@/data/report";
import { identityTransformer, Transformer } from "@/serialization";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
import { Field, FieldArray, FieldProps, useFormikContext } from "formik";
import { TextField as TextFieldFormik, Select } from "formik-material-ui";
import { FormControl, InputLabel, Typography, MenuItem, Card, Box, Button, CardContent, CardHeader, IconButton } from "@material-ui/core";
import { CKEditorField } from "@/field/ckeditor";
import { Multilingual } from "@/data";
import { Actions } from "@/components";
import { Add } from "@material-ui/icons";
import { TrashCan } from "mdi-material-ui";
import { FieldPreview } from "@/management/report/fields/list";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
export type FieldDefinitionFormValues = ReportFieldDefinition | { type: string };
export const initialFieldFormValues: FieldDefinitionFormValues = {
type: "short-text",
description: {
pl: "",
en: "",
},
label: {
pl: "",
en: "",
},
choices: [],
}
export const fieldFormValuesTransformer: Transformer<ReportFieldDefinition, FieldDefinitionFormValues> = identityTransformer;
export type ChoiceFieldProps = { name: string };
const ChoiceField = ({ field, form, meta }: FieldProps) => {
const { name } = field;
const { t } = useTranslation("management");
const spacing = useSpacing(2);
return <div className={ spacing.vertical }>
<Field label={ t("translation:language.pl") } name={`${name}.pl`} fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name={`${name}.en`} fullWidth component={ TextFieldFormik }/>
</div>
}
const useStyles = makeStyles((theme: Theme) => createStyles({
preview: {
padding: theme.spacing(2),
backgroundColor: "#e9f0f5",
},
}))
export function FieldDefinitionForm() {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
const { values } = useFormikContext<any>();
const classes = useStyles();
return <div className={ spacing.vertical }>
<FormControl variant="outlined">
<InputLabel htmlFor="report-field-type">{ t("report-field.field.type") }</InputLabel>
<Field
component={Select}
name="type"
label={ t("report-field.field.name") }
inputProps={{ id: 'report-field-type', }}
>
{ reportFieldTypes.map(type => <MenuItem value={ type }>{ t(`report-field.type.${type}`) }</MenuItem>)}
</Field>
</FormControl>
<Typography variant="subtitle2">{ t("report-field.field.label") }</Typography>
<Field label={ t("translation:language.pl") } name="label.pl" fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name="label.en" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("report-field.field.description") }</Typography>
<Field label={ t("translation:language.pl") } name="description.pl" fullWidth component={ CKEditorField }/>
<Field label={ t("translation:language.en") } name="description.en" fullWidth component={ CKEditorField }/>
{ ["radio", "select", "checkbox"].includes(values.type) && <>
<Typography variant="subtitle2">{ t("report-field.field.choices") }</Typography>
<FieldArray name="choices" render={ helper => <>
{ values.choices.map((value: Multilingual<string>, index: number) => <Card>
<CardHeader subheader={ t("report-field.field.choice", { index: index + 1 }) } action={ <>
<IconButton onClick={ () => helper.remove(index) }>
<TrashCan />
</IconButton>
</> }/>
<CardContent>
<Field name={`choices[${index}]`} component={ ChoiceField } />
</CardContent>
</Card>) }
<Actions>
<Button variant="contained" startIcon={ <Add /> } color="primary" onClick={() => helper.push({ pl: "", en: "" })}>{ t("actions.add") }</Button>
</Actions>
</> } />
</> }
<div className={ classes.preview }>
<Typography variant="subtitle2">{ t("report-field.preview") }</Typography>
<FieldPreview field={ fieldFormValuesTransformer.reverseTransform(values) }/>
</div>
</div>
}

View File

@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useState } from "react";
import { Page } from "@/pages/base";
import { Management } from "@/management/main";
import { Box, Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
import MaterialTable, { Column } from "material-table";
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
import { MultilingualCell } from "@/management/common/MultilangualCell";
import { ReportFieldDefinition } from "@/data/report";
import { Formik } from "formik";
import { CustomField } from "@/forms/report";
import { Add, Edit } from "@material-ui/icons";
import { createPortal } from "react-dom";
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
import { EditFieldDefinitionDialog } from "@/management/report/fields/edit";
import api from "@/management/api";
import { useAsync, useAsyncState } from "@/hooks";
import { Async } from "@/components/async";
import { Actions } from "@/components";
import { Refresh } from "mdi-material-ui";
import { useSpacing } from "@/styles";
const title = "report-fields.title";
export const FieldPreview = ({ field }: { field: ReportFieldDefinition }) => {
return <Formik initialValues={{}} onSubmit={() => {}}>
<CustomField field={ field }/>
</Formik>
}
export const ReportFields = () => {
const { t } = useTranslation("management");
const [fields, setFieldsPromise] = useAsyncState<ReportFieldDefinition[]>();
const updateFieldList = () => {
setFieldsPromise(api.field.all());
}
useEffect(updateFieldList, []);
const handleFieldDeletion = () => {}
const CreateFieldAction = () => {
const [ open, setOpen ] = useState<boolean>(false);
const handleFieldCreation = async (value: ReportFieldDefinition) => {
await api.field.save(value);
setOpen(false);
}
return <>
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
{ open && createPortal(
<EditFieldDefinitionDialog open={ open } onSave={ handleFieldCreation } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const DeleteFieldAction = createDeleteAction<ReportFieldDefinition>({ label: field => field.label.pl, onDelete: handleFieldDeletion })
const EditFieldAction = ({ field }: { field: ReportFieldDefinition }) => {
const [ open, setOpen ] = useState<boolean>(false);
const handleFieldSave = async (field: ReportFieldDefinition) => {
await api.field.save(field);
setOpen(false);
}
return <>
<Tooltip title={ t("actions.edit") as any }>
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
</Tooltip>
{ open && createPortal(
<EditFieldDefinitionDialog open={ open } onSave={ handleFieldSave } field={ field } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const columns: Column<ReportFieldDefinition>[] = [
{
title: t("report-field.field.label"),
customSort: fieldComparator('label', multilingualStringComparator),
cellStyle: { whiteSpace: "nowrap" },
render: field => <MultilingualCell value={ field.label }/>,
},
{
title: t("report-field.field.type"),
cellStyle: { whiteSpace: "nowrap" },
render: field => t(`report-field.type.${field.type}`),
},
actionsColumn(field => <>
<EditFieldAction field={ field }/>
<DeleteFieldAction resource={ field }/>
</>),
]
const spacing = useSpacing(2);
return <Page>
<Page.Header maxWidth="lg">
<Management.Breadcrumbs>
<Typography color="textPrimary">{ t(title) }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ t(title) }</Page.Title>
</Page.Header>
<Container maxWidth="lg" className={ spacing.vertical }>
<Actions>
<CreateFieldAction />
<Button onClick={ updateFieldList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
</Actions>
<Async async={ fields }>
{ fields => <MaterialTable
columns={ columns }
data={ fields }
title={ t(title) }
detailPanel={ field => <Box p={3}><FieldPreview field={ field } /></Box> }
/> }
</Async>
</Container>
</Page>
}

View File

@ -5,11 +5,29 @@ import React from "react";
import { ManagementIndex } from "@/management/main";
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 { 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 },
{ 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_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 },
{ name: "report_fields", path: "/fields", content: ReportFields },
{ name: "types", path: "/types", content: InternshipTypeManagement },
{ name: "static_pages", path: "/static-pages", content: StaticPageManagement }
] as Route[]).map(

View File

@ -17,10 +17,8 @@ import { BulkActions } from "@/management/common/BulkActions";
import { useSpacing } from "@/styles";
import { Actions } from "@/components";
import { MultilingualCell } from "@/management/common/MultilangualCell";
import { default as StaticPage } from "@/data/page";
import { Add, Edit } from "@material-ui/icons";
import { createPortal } from "react-dom";
import { EditStaticPageDialog } from "@/management/page/edit";
import { EditInternshipTypeDialog } from "@/management/type/edit";
const title = "type.index.title";

View File

@ -8,7 +8,7 @@ export type PageProps = {
} & BoxProps;
export type PageHeaderProps = {
maxWidth?: "sm" | "md" | "lg" | false
maxWidth?: "sm" | "md" | "lg" | "xl" | false
} & HTMLProps<HTMLDivElement>
export const Page = ({ title, children, ...props }: PageProps) => {

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

@ -0,0 +1,26 @@
import { Page } from "@/pages/base";
import { Container, Link, Typography } from "@material-ui/core";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import React from "react";
import { useTranslation } from "react-i18next";
import ReportForm from "@/forms/report";
export const SubmitReportPage = () => {
const { t } = useTranslation();
return <Page title={ t("steps.report.submit") }>
<Page.Header maxWidth="md">
<Page.Breadcrumbs>
<Link component={ RouterLink } to={ route("home") }>{ t('pages.my-internship.header') }</Link>
<Typography color="textPrimary">{ t("steps.report.submit") }</Typography>
</Page.Breadcrumbs>
<Page.Title>{ t("steps.report.submit") }</Page.Title>
</Page.Header>
<Container maxWidth={ "md" }>
<ReportForm/>
</Container>
</Page>
}
export default SubmitReportPage;

View File

@ -13,11 +13,11 @@ 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";
import { AppDispatch, InternshipPlanActions, InternshipProposalActions, InternshipReportActions, useDispatch } from "@/state/actions";
import { internshipDocumentDtoTransformer, internshipRegistrationDtoTransformer, internshipReportDtoTransformer } from "@/api/dto/internship-registration";
import { UploadType } from "@/api/upload";
import { ReportStep } from "@/pages/steps/report";
export const updateInternshipInfo = async (dispatch: AppDispatch) => {
const internship = await api.internship.get();
@ -25,22 +25,38 @@ export const updateInternshipInfo = async (dispatch: AppDispatch) => {
dispatch({
type: InternshipProposalActions.Receive,
state: internship.internshipRegistration.state,
comment: internship.internshipRegistration.changeStateComment,
internship: internshipRegistrationDtoTransformer.transform(internship.internshipRegistration),
})
const plan = internship.documentation.find(doc => doc.type === UploadType.Ipp);
const report = internship.report;
if (plan) {
dispatch({
type: InternshipPlanActions.Receive,
document: plan,
document: internshipDocumentDtoTransformer.transform(plan),
state: plan.state,
comment: plan.changeStateComment,
})
} else {
dispatch({
type: InternshipPlanActions.Reset,
})
}
if (report) {
dispatch({
type: InternshipReportActions.Receive,
report: internshipReportDtoTransformer.transform(report),
state: report.state,
comment: report.changeStateComment,
})
} else {
dispatch({
type: InternshipReportActions.Reset,
})
}
}
export const MainPage = () => {
@ -48,10 +64,8 @@ export const MainPage = () => {
const student = useSelector<AppState, Student | null>(state => state.student);
const deadlines = useDeadlines();
const insurance = useSelector<AppState, InsuranceState>(root => root.insurance);
const dispatch = useDispatch();
const edition = useCurrentEdition();
useEffect(() => {
dispatch(updateInternshipInfo);
@ -69,7 +83,7 @@ export const MainPage = () => {
if (insurance.required)
yield <InsuranceStep key="insurance"/>;
yield <Step label={ t('steps.report.header') } until={ deadlines.report } notBefore={ edition?.reportingStart } key="report"/>
yield <ReportStep key="report"/>;
yield <Step label={ t('steps.grade.header') } key="grade"/>
}

View File

@ -3,7 +3,7 @@ import { AppState } from "@/state/reducer";
import { getSubmissionStatus, SubmissionState, SubmissionStatus } from "@/state/reducer/submission";
import { useTranslation } from "react-i18next";
import { Box, Button, ButtonProps, StepProps } from "@material-ui/core";
import { FileDownloadOutline, FileUploadOutline } from "mdi-material-ui/index";
import { FileUploadOutline } from "mdi-material-ui/index";
import { route } from "@/routing";
import { Link as RouterLink, useHistory } from "react-router-dom";
import { Actions, Step } from "@/components";
@ -15,7 +15,6 @@ import { useDeadlines } from "@/hooks";
import { InternshipDocument } from "@/api/dto/internship-registration";
import { FileInfo } from "@/components/fileinfo";
import { useSpacing } from "@/styles";
import { AcceptanceActions } from "@/components/acceptance-action";
import { InternshipPlanActions, useDispatch } from "@/state/actions";
const PlanActions = () => {
@ -48,7 +47,6 @@ const PlanActions = () => {
switch (status) {
case "awaiting":
return <Actions>
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="plan" />
</Actions>
case "accepted":
return <Actions>
@ -77,7 +75,7 @@ export const PlanComment = (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

@ -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
}

154
src/pages/steps/report.tsx Normal file
View File

@ -0,0 +1,154 @@
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { getSubmissionStatus, SubmissionState, SubmissionStatus } from "@/state/reducer/submission";
import { useTranslation } from "react-i18next";
import { Box, Button, ButtonProps, Dialog, DialogContent, DialogProps, DialogTitle, StepProps, Typography } from "@material-ui/core";
import { FileFind, FileUploadOutline } from "mdi-material-ui/index";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { Actions, Step } from "@/components";
import React, { HTMLProps, useState } from "react";
import { Alert, AlertTitle } from "@material-ui/lab";
import { ContactButton, Status } from "@/pages/steps/common";
import { useCurrentEdition, useDeadlines } from "@/hooks";
import { useSpacing } from "@/styles";
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 <ul>{ ((value as MultiChoiceValue).map(selection => <li>{ selection.pl }</li>)) }</ul>
case "radio":
case "select":
return <div>{ (value as SingleChoiceValue).pl }</div>
case "long-text":
case "short-text":
return <p>{ value as TextFieldValue }</p>
}
}
return <>
<Typography variant="subtitle2">{ field.label.pl }</Typography>
{ value ? <Value/> : t("no-value") }
</>
}) }</>
}
export type ReportPreviewDialogProps = {
report: Report;
} & DialogProps;
export const ReportPreviewDialog = ({ report, ...props }: ReportPreviewDialogProps) => {
const edition = useCurrentEdition() as Edition;
const schema = edition.schema || [];
const { t } = useTranslation();
return <Dialog { ...props } maxWidth="md" fullWidth>
<DialogTitle>{ t("steps.report.header") }</DialogTitle>
<DialogContent><ReportPreview schema={ schema } report={ report }/></DialogContent>
</Dialog>
}
const ReportActions = () => {
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.report));
const { t } = useTranslation();
const FormAction = ({ children = t('steps.report.submit'), ...props }: ButtonProps) =>
<Button to={ route("internship_report") } variant="contained" color="primary" component={ RouterLink }
startIcon={ <FileUploadOutline/> } { ...props as any }>
{ children }
</Button>
const ReviewAction = (props: ButtonProps) => {
const [open, setOpen,] = useState<boolean>(false);
const report = useSelector<AppState, Report>(state => getInternshipReport(state.report) as Report);
return <>
<Button startIcon={ <FileFind/> }
onClick={ () => setOpen(true) }
{ ...props as any }>
{ t('review') }
</Button>
{ createPortal(
<ReportPreviewDialog report={ report } open={ open } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element,
) }
</>
}
switch (status) {
case "awaiting":
return <Actions>
<ReviewAction/>
<FormAction>{ t('send-again') }</FormAction>
</Actions>
case "accepted":
return <Actions>
<FormAction variant="outlined" color="secondary">{ t('send-again') }</FormAction>
</Actions>
case "declined":
return <Actions>
<FormAction>{ t('send-again') }</FormAction>
<ContactButton/>
</Actions>
case "draft":
return <Actions>
<FormAction/>
</Actions>
default:
return <Actions/>
}
}
export const PlanComment = (props: HTMLProps<HTMLDivElement>) => {
const { comment, declined } = useSelector<AppState, SubmissionState>(state => state.plan);
const { t } = useTranslation();
return comment ? <Alert severity={ declined ? "error" : "warning" } { ...props as any }>
<AlertTitle>{ t('comments') }</AlertTitle>
{ comment }
</Alert> : null
}
export const ReportStep = (props: StepProps) => {
const { t } = useTranslation();
const submission = useSelector<AppState, SubmissionState>(state => state.report);
const spacing = useSpacing(2);
const edition = useCurrentEdition();
const status = getSubmissionStatus(submission);
const deadlines = useDeadlines();
const { sent, declined, comment } = submission;
return <Step { ...props }
label={ t('steps.report.header') }
active={ true } completed={ sent } declined={ declined } waiting={ status == "awaiting" }
until={ deadlines.report }
notBefore={ edition?.reportingStart }
state={ <Status submission={ submission }/> }>
<div className={ spacing.vertical }>
<p>{ t(`steps.report.info.${ status }`) }</p>
{ comment && <Box pb={ 2 }><PlanComment/></Box> }
<ReportActions/>
</div>
</Step>;
}

View File

@ -0,0 +1,83 @@
import { Report, ReportSchema } from "@/data/report";
import { Stateful } from "@/data";
const choices = [1, 2, 3, 4, 5].map(n => ({
pl: `Wybór ${n}`,
en: `Choice ${n}`
}))
export const sampleReportSchema: ReportSchema = [
{
type: "short-text",
id: "short",
description: {
en: "Text field, with <strong>HTML</strong> description",
pl: "Pole tekstowe, z opisem w formacie <strong>HTML</strong>"
},
label: {
en: "Text Field",
pl: "Pole tekstowe",
},
},
{
type: "long-text",
id: "long",
description: {
en: "Long text field, with <strong>HTML</strong> description",
pl: "Długie pole tekstowe, z opisem w formacie <strong>HTML</strong>"
},
label: {
en: "Long Text Field",
pl: "Długie Pole tekstowe",
},
},
{
type: "radio",
id: "radio",
description: {
en: "single choice field, with <strong>HTML</strong> description",
pl: "Pole jednokrotnego wyboru, z opisem w formacie <strong>HTML</strong>"
},
choices,
label: {
en: "Single choice field",
pl: "Pole jednokrotnego wyboru",
},
},
{
type: "select",
id: "select",
description: {
en: "select field, with <strong>HTML</strong> description",
pl: "Pole jednokrotnego wyboru z selectboxem, z opisem w formacie <strong>HTML</strong>"
},
choices,
label: {
en: "Select field",
pl: "Pole jednokrotnego wyboru (selectbox)",
},
},
{
type: "checkbox",
id: "multi",
description: {
en: "Multiple choice field, with <strong>HTML</strong> description",
pl: "Pole wielokrotnego wyboru, z opisem w formacie <strong>HTML</strong>"
},
choices,
label: {
en: "Multi choice field",
pl: "Pole wielokrotnego wyboru",
},
},
]
export const emptyReport: Report = {
fields: {
"field_short": "Testowa wartość",
"field_select": choices[0],
"field_multi": [ choices[1], choices[2] ],
},
comment: "",
state: "draft",
}

View File

@ -1,6 +1,6 @@
import React, { ReactComponentElement } from "react";
import { MainPage } from "@/pages/main";
import { RouteProps } from "react-router-dom";
import { RouteProps, Switch, Route as RouteComponent } from "react-router-dom";
import { InternshipProposalFormPage, InternshipProposalPreviewPage } from "@/pages/internship/proposal";
import { FallbackPage } from "@/pages/fallback";
import SubmitPlanPage from "@/pages/internship/plan";
@ -11,10 +11,12 @@ import { isLoggedInMiddleware, isReadyMiddleware } from "@/middleware";
import UserFillPage from "@/pages/user/fill";
import UserProfilePage from "@/pages/user/profile";
import { managementRoutes } from "@/management/routing";
import SubmitReportPage from "@/pages/internship/report";
export type Route = {
name?: string;
content: () => ReactComponentElement<any>,
tags?: string[];
content: (props?: any) => ReactComponentElement<any>,
condition?: () => boolean,
middlewares?: Middleware<any, any>[],
} & RouteProps;
@ -44,6 +46,7 @@ export const routes: Route[] = [
{ name: "internship_proposal", path: "/internship/proposal", exact: true, content: () => <InternshipProposalFormPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_proposal_preview", path: "/internship/preview/proposal", exact: true, content: () => <InternshipProposalPreviewPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_plan", path: "/internship/plan", exact: true, content: () => <SubmitPlanPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_report", path: "/internship/report", exact: true, content: () => <SubmitReportPage/>, middlewares: [ isReadyMiddleware ] },
// user
{ name: "user_login", path: "/user/login", content: () => <UserLoginPage/> },
@ -54,7 +57,7 @@ export const routes: Route[] = [
// fallback route for 404 pages
{ name: "fallback", path: "*", content: () => <FallbackPage/> },
]
].map(route => ({ tags: [], ...route }))
const routeNameMap = new Map(routes.filter(({ name }) => !!name).map(({ name, path }) => [name, path instanceof Array ? path[0] : path])) as Map<string, string>
@ -78,3 +81,19 @@ export const query = (url: string, params: URLParams) => {
return url + (query.length > 0 ? `?${ query }` : '');
}
export type RoutesProps = {
routes: Route[];
[prop: string]: any;
};
export function Routes({ routes, ...props }: RoutesProps) {
return <Switch>
{ routes.map(({ name, content, middlewares = [], ...route }) =>
<RouteComponent { ...route } key={ name } render={ () => {
const Next = () => processMiddlewares([ ...middlewares, (_, ...props) => content(...props) ], props)
return <Next />
} } />
) }
</Switch>
}

View File

@ -14,6 +14,9 @@ export const editionSerializationTransformer: SerializationTransformer<Edition>
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<Edition>, context?: unknown): Edition {
@ -26,6 +29,9 @@ export const editionSerializationTransformer: SerializationTransformer<Edition>
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
}
},
}

View File

@ -0,0 +1,4 @@
import { identityTransformer, Serializable, Transformer } from "@/serialization/types";
import { Report } from "@/data/report";
export const reportSerializationTransformer: Transformer<Report, Serializable<Report>> = identityTransformer;

View File

@ -9,6 +9,7 @@ import { UserAction, UserActions } from "@/state/actions/user";
import { ThunkDispatch } from "redux-thunk";
import { AppState } from "@/state/reducer";
import { StudentAction, StudentActions } from "@/state/actions/student";
import { InternshipReportAction, InternshipReportActions } from "@/state/actions/report";
export * from "./base"
export * from "./edition"
@ -17,6 +18,7 @@ export * from "./proposal"
export * from "./plan"
export * from "./user"
export * from "./student"
export * from "./report"
export type Action
= UserAction
@ -25,6 +27,7 @@ export type Action
| InternshipProposalAction
| StudentAction
| InternshipPlanAction
| InternshipReportAction
| InsuranceAction;
export const Actions = {
@ -35,6 +38,7 @@ export const Actions = {
...InternshipPlanActions,
...InsuranceActions,
...StudentActions,
...InternshipReportActions,
}
export type Actions = typeof Actions;
export type AppDispatch = ThunkDispatch<AppState, any, Action>;

View File

@ -33,6 +33,7 @@ export interface ReceivePlanDeclineAction extends ReceiveSubmissionDeclineAction
export interface ReceivePlanUpdateAction extends ReceiveSubmissionUpdateAction<InternshipPlanActions.Receive> {
document: InternshipDocument;
state: SubmissionState;
comment?: string;
}
export interface SavePlanAction extends SaveSubmissionAction<InternshipPlanActions.Save> {

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

@ -0,0 +1,49 @@
import { Internship } from "@/data";
import {
ReceiveSubmissionApproveAction,
ReceiveSubmissionDeclineAction,
ReceiveSubmissionUpdateAction,
SaveSubmissionAction,
SendSubmissionAction
} from "@/state/actions/submission";
import { SubmissionState } from "@/api/dto/internship-registration";
import { Report } from "@/data/report";
export enum InternshipReportActions {
Send = "SEND_REPORT",
Save = "SAVE_REPORT",
Approve = "RECEIVE_REPORT_APPROVE",
Decline = "RECEIVE_REPORT_DECLINE",
Receive = "RECEIVE_REPORT_STATE",
Reset = "RESET_REPORT",
}
export interface SendReportAction extends SendSubmissionAction<InternshipReportActions.Send> {
}
export interface ResetReportAction extends SendSubmissionAction<InternshipReportActions.Reset> {
}
export interface ReceiveReportApproveAction extends ReceiveSubmissionApproveAction<InternshipReportActions.Approve> {
}
export interface ReceiveReportDeclineAction extends ReceiveSubmissionDeclineAction<InternshipReportActions.Decline> {
}
export interface ReceiveReportUpdateAction extends ReceiveSubmissionUpdateAction<InternshipReportActions.Receive> {
report: Report;
state: SubmissionState,
comment: string,
}
export interface SaveReportAction extends SaveSubmissionAction<InternshipReportActions.Save> {
report: Report;
}
export type InternshipReportAction
= SendReportAction
| SaveReportAction
| ResetReportAction
| ReceiveReportApproveAction
| ReceiveReportDeclineAction
| ReceiveReportUpdateAction;

View File

@ -7,6 +7,7 @@ import internshipProposalReducer from "@/state/reducer/proposal";
import internshipPlanReducer from "@/state/reducer/plan";
import insuranceReducer from "@/state/reducer/insurance";
import userReducer from "@/state/reducer/user";
import internshipReportReducer from "@/state/reducer/report";
const rootReducer = combineReducers({
student: studentReducer,
@ -16,6 +17,7 @@ const rootReducer = combineReducers({
plan: internshipPlanReducer,
insurance: insuranceReducer,
user: userReducer,
report: internshipReportReducer,
})
export type AppState = ReturnType<typeof rootReducer>;

View File

@ -54,6 +54,7 @@ const internshipPlanReducer = (state: InternshipPlanState = defaultInternshipPla
ApiSubmissionState.Rejected,
ApiSubmissionState.Submitted
].includes(action.state),
comment: action.comment || null,
document: action.document,
}

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

@ -0,0 +1,69 @@
import { InternshipReportAction, InternshipReportActions } from "@/state/actions";
import { Serializable } from "@/serialization/types";
import {
createSubmissionReducer,
defaultDeanApprovalsState,
defaultSubmissionState,
SubmissionState
} from "@/state/reducer/submission";
import { Reducer } from "react";
import { SubmissionAction } from "@/state/actions/submission";
import { SubmissionState as ApiSubmissionState } from "@/api/dto/internship-registration";
import { Report } from "@/data/report";
import { reportSerializationTransformer } from "@/serialization/report";
export type InternshipReportState = SubmissionState & {
report: Serializable<Report> | null;
}
const defaultInternshipReportState: InternshipReportState = {
...defaultDeanApprovalsState,
...defaultSubmissionState,
report: null,
}
export const getInternshipReport = ({ report }: InternshipReportState): Report | null =>
report && reportSerializationTransformer.reverseTransform(report);
const internshipReportSubmissionReducer: Reducer<InternshipReportState, InternshipReportAction> = createSubmissionReducer({
[InternshipReportActions.Approve]: SubmissionAction.Approve,
[InternshipReportActions.Decline]: SubmissionAction.Decline,
[InternshipReportActions.Receive]: SubmissionAction.Receive,
[InternshipReportActions.Save]: SubmissionAction.Save,
[InternshipReportActions.Send]: SubmissionAction.Send,
})
const internshipReportReducer = (state: InternshipReportState = defaultInternshipReportState, action: InternshipReportAction): InternshipReportState => {
state = internshipReportSubmissionReducer(state, action);
switch (action.type) {
case InternshipReportActions.Reset:
return defaultInternshipReportState;
case InternshipReportActions.Save:
case InternshipReportActions.Send:
return {
...state,
}
case InternshipReportActions.Receive:
if (state.overwritten) {
return state;
}
return {
...state,
accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [
ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected,
ApiSubmissionState.Submitted
].includes(action.state),
report: reportSerializationTransformer.transform(action.report),
comment: action.comment,
}
default:
return state;
}
}
export default internshipReportReducer;

View File

@ -11,15 +11,75 @@ actions:
preview: Podgląd
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
index:
title: "Edycje praktyk"
reports:
title: "Raporty praktyki"
dean-approvals:
title: "Zgody dziekana"
field:
id: Identyfikator
start: Początek
end: Koniec
course: Kierunek
reportingStart: Początek raportowania
reportingEnd: Koniec raportowania
proposalDeadline: Termin zgłaszania praktyk
minimumInternshipHours: Minimalna liczba godzin
fields:
basic: "Podstawowe"
deadlines: "Terminy"
program: "Ramowy program praktyk"
types: "Dostępne typy 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
field:
type: "Rodzaj"
name: "Unikalny identyfikator"
label: "Etykieta"
description: "Opis"
choices: "Możliwe wybory"
choice: "Wybór #{{ index }}"
type:
select: "Pole wyboru"
radio: "Jednokrotny wybór (radio)"
checkbox: "Wielokrotny wybór (checkboxy)"
short-text: "Pole krótkiej odpowiedzi"
long-text: "Pole długiej odpowiedzi"
type:
index:

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:

View File

@ -6842,9 +6842,9 @@ md5.js@^1.3.4:
safe-buffer "^5.1.2"
mdi-material-ui@^6.17.0:
version "6.17.0"
resolved "https://registry.yarnpkg.com/mdi-material-ui/-/mdi-material-ui-6.17.0.tgz#da69f0b7d7c6fc2255e6007ed8b8ca858c1aede7"
integrity sha512-eOprRu31lklPIS1WGe3cM0G/8glKl1WKRvewxjDrgXH2Ryxxg7uQ+uwDUwUEONtLku0p2ZOLzgXUIy2uRy5rLg==
version "6.21.0"
resolved "https://registry.yarnpkg.com/mdi-material-ui/-/mdi-material-ui-6.21.0.tgz#e052215b0534e6c20abeb7e89c3fd8a421a519fd"
integrity sha512-rcO7KmaOhZq4H7vHYpwnjMqHfuJh0PmpEJNssEofWaqoSEABmIwRHUNmdJDPrjrBCTUm4m7tpYexqPOYzkb1Eg==
mdn-data@2.0.4:
version "2.0.4"