Add subject selection

This commit is contained in:
Kacper Donat 2020-11-07 23:33:23 +01:00
parent 9977f5678c
commit 0ff80a454d
32 changed files with 190 additions and 49 deletions

View File

@ -40,7 +40,8 @@
"jsonwebtoken": "^8.5.1",
"material-ui-dropzone": "^3.3.0",
"mdi-material-ui": "^6.17.0",
"moment": "^2.26.0",
"moment-timezone": "^2.26.0",
"moment-timezone": "^0.5.31",
"node-sass": "^4.14.1",
"optimize-css-assets-webpack-plugin": "5.0.3",
"postcss-flexbugs-fixes": "4.1.0",

View File

@ -1,15 +1,21 @@
import { Identifiable } from "@/data";
import { Identifiable, InternshipProgramEntry } from "@/data";
import { CourseDTO, courseDtoTransformer } from "@/api/dto/course";
import { OneWayTransformer, Transformer } from "@/serialization";
import { Edition } from "@/data/edition";
import moment from "moment";
import moment from "moment-timezone";
import { Subset } from "@/helpers";
export interface ProgramEntryDTO extends Identifiable {
description: string;
descriptionEng: string;
}
export interface EditionDTO extends Identifiable {
editionStart: string,
editionFinish: string,
reportingStart: string,
course: CourseDTO,
availableSubjects: ProgramEntryDTO[],
}
export interface EditionTeaserDTO extends Identifiable {
@ -39,6 +45,7 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
editionStart: subject.startDate.toISOString(),
course: courseDtoTransformer.reverseTransform(subject.course),
reportingStart: subject.reportingStart.toISOString(),
availableSubjects: [],
};
},
transform(subject: EditionDTO, context: undefined): Edition {
@ -55,3 +62,19 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
};
}
}
export const programEntryDtoTransformer: Transformer<ProgramEntryDTO, InternshipProgramEntry> = {
transform(subject: ProgramEntryDTO, context: never): InternshipProgramEntry {
return {
id: subject.id,
description: subject.description,
}
},
reverseTransform(subject: InternshipProgramEntry, context: never): ProgramEntryDTO {
return {
id: subject.id,
description: subject.description,
descriptionEng: "",
}
},
}

View File

@ -3,7 +3,7 @@ import { momentSerializationTransformer, OneWayTransformer } from "@/serializati
import { Nullable } from "@/helpers";
import { MentorDTO, mentorDtoTransformer } from "@/api/dto/mentor";
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
import { Moment } from "moment";
import { Moment } from "moment-timezone";
import { sampleStudent } from "@/provider/dummy";
import { UploadType } from "@/api/upload";
@ -36,6 +36,7 @@ export interface InternshipRegistrationUpdate {
type: number,
mentor: MentorDTO,
hours: number,
subjects: string[],
}
export interface InternshipRegistrationDTO extends Identifiable {
@ -65,8 +66,8 @@ export interface InternshipInfoDTO {
export const internshipRegistrationUpdateTransformer: OneWayTransformer<Nullable<Internship>, Nullable<InternshipRegistrationUpdate>> = {
transform(subject: Nullable<Internship>, context?: unknown): Nullable<InternshipRegistrationUpdate> {
return {
start: subject?.startDate?.toISOString() || null,
end: subject?.endDate?.toISOString() || null,
start: momentSerializationTransformer.transform(subject?.startDate) || null,
end: momentSerializationTransformer.transform(subject?.endDate) || null,
type: parseInt(subject?.type?.id || "0"),
mentor: mentorDtoTransformer.reverseTransform(subject.mentor as Mentor),
company: subject?.company?.id ? {
@ -80,6 +81,7 @@ export const internshipRegistrationUpdateTransformer: OneWayTransformer<Nullable
branchOffice: subject?.office?.address as NewBranchOffice
},
hours: subject?.hours,
subjects: subject?.program?.map(program => program.id as string) || [],
}
}
}

View File

@ -1,8 +1,9 @@
import { axios } from "@/api/index";
import { Edition } from "@/data/edition";
import { prepare } from "@/routing";
import { EditionDTO, editionDtoTransformer, EditionTeaserDTO, editionTeaserDtoTransformer } from "@/api/dto/edition";
import { EditionDTO, editionDtoTransformer, EditionTeaserDTO, editionTeaserDtoTransformer, programEntryDtoTransformer } from "@/api/dto/edition";
import { Subset } from "@/helpers";
import { InternshipProgramEntry } from "@/data";
const EDITIONS_ENDPOINT = "/editions";
const EDITION_INFO_ENDPOINT = "/editions/:key";
@ -41,11 +42,17 @@ export async function get(key: string): Promise<Subset<Edition> | null> {
return editionTeaserDtoTransformer.transform(dto);
}
export async function current(): Promise<Edition> {
export async function current(): Promise<{
edition: Edition,
program: InternshipProgramEntry[],
}> {
const response = await axios.get<EditionDTO>(EDITION_CURRENT_ENDPOINT);
const dto = response.data;
return editionDtoTransformer.transform(dto);
return {
edition: editionDtoTransformer.transform(dto),
program: dto.availableSubjects.map(programEntryDtoTransformer.transform as any),
};
}
export async function login(key: string): Promise<string> {

View File

@ -9,12 +9,11 @@ import '@/styles/overrides.scss'
import '@/styles/header.scss'
import '@/styles/footer.scss'
import classNames from "classnames";
import { Edition } from "@/data/edition";
import { SettingActions } from "@/state/actions/settings";
import { useDispatch, UserActions } from "@/state/actions";
import { getLocale, Locale } from "@/state/reducer/settings";
import i18n from "@/i18n";
import moment from "moment";
import moment from "moment-timezone";
import { Container } from "@material-ui/core";
const UserMenu = (props: HTMLProps<HTMLUListElement>) => {
@ -61,8 +60,6 @@ const LanguageSwitcher = ({ className, ...props }: HTMLProps<HTMLUListElement>)
}
function App() {
const dispatch = useDispatch();
const edition = useSelector<AppState, Edition | null>(state => state.edition as any);
const { t } = useTranslation();
const locale = useSelector<AppState, Locale>(state => getLocale(state.settings));

View File

@ -2,6 +2,7 @@ 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";
type AsyncProps<TValue, TError = any> = {
async: AsyncResult<TValue>,
@ -10,7 +11,7 @@ type AsyncProps<TValue, TError = any> = {
error?: (error: TError) => JSX.Element,
}
const defaultLoading = () => <CircularProgress />;
const defaultLoading = () => <Loading />;
const defaultError = (error: any) => <Alert severity="error">{ error.message }</Alert>;
export function Async<TValue, TError = any>(

View File

@ -0,0 +1,28 @@
import React from "react";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import { CircularProgress, Typography } from "@material-ui/core";
const useStyles = makeStyles(theme => createStyles({
root: {
display: "flex",
flexDirection: "column",
alignItems: "center",
"& > :not(:last-child)": {
marginBottom: theme.spacing(2),
}
}
}))
export type LoadingProps = {
size?: string | number;
label?: string;
};
export function Loading({ size, label, ...props }: LoadingProps) {
const classes = useStyles();
return <div className={ classes.root } { ...props }>
<CircularProgress size={ size }/>
{ label && <Typography variant="subtitle1" color="primary">{ label }</Typography> }
</div>
}

View File

@ -4,7 +4,7 @@ import { Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { useVerticalSpacing } from "@/styles";
import moment from "moment";
import moment from "moment-timezone";
import { Label, Section } from "@/components/section";
import { StudentPreview } from "@/pages/user/profile";

View File

@ -1,4 +1,4 @@
import moment, { Moment } from "moment";
import moment, { Moment } from "moment-timezone";
import { Box, Step as StepperStep, StepContent, StepLabel, StepProps as StepperStepProps, Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
import React, { ReactChild, useMemo } from "react";

View File

@ -5,5 +5,4 @@ import { Identifiable } from "./common";
export interface Course extends Identifiable {
name: string,
desiredSemesters: Semester[],
possibleProgramEntries: InternshipProgramEntry[];
}

View File

@ -1,4 +1,4 @@
import { Moment } from "moment";
import { Moment } from "moment-timezone";
import { Course } from "@/data/course";
import { Identifiable } from "@/data/common";

View File

@ -1,4 +1,4 @@
import { Moment } from "moment";
import { Moment } from "moment-timezone";
import { Identifiable, Multilingual } from "./common";
import { Student } from "@/data/student";
import { Company, Office } from "@/data/company";

View File

@ -1,12 +1,24 @@
import React, { HTMLProps, useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, Grid, TextField, Typography } from "@material-ui/core";
import React, { HTMLProps, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
Grid,
TextField,
Typography,
FormGroup,
FormControlLabel,
Checkbox
} from "@material-ui/core";
import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers";
import { CompanyForm } from "@/forms/company";
import { StudentForm } from "@/forms/student";
import { sampleStudent } from "@/provider/dummy/student";
import { Company, Internship, InternshipType, Office, Student } from "@/data";
import { Company, Internship, InternshipProgramEntry, InternshipType, Office, Student } from "@/data";
import { Nullable } from "@/helpers";
import moment, { Moment } from "moment";
import moment, { Moment } from "moment-timezone";
import { computeWorkingHours } from "@/utils/date";
import { Autocomplete } from "@material-ui/lab";
import { emptyInternship } from "@/provider/dummy/internship";
@ -25,6 +37,8 @@ import { TextField as TextFieldFormik } from "formik-material-ui"
import { useCurrentEdition, useCurrentStudent, useInternshipTypes, useUpdateEffect } from "@/hooks";
import { internshipRegistrationUpdateTransformer } from "@/api/dto/internship-registration";
import api from "@/api";
import FormLabel from "@material-ui/core/FormLabel";
import { CheckBox } from "@material-ui/icons";
export type InternshipFormValues = {
startDate: Moment | null;
@ -43,6 +57,7 @@ export type InternshipFormValues = {
mentorEmail: string;
mentorPhone: string;
kindOther: string | null;
program: InternshipProgramEntry[];
// relations
kind: InternshipType | null;
@ -72,6 +87,7 @@ const emptyInternshipValues: InternshipFormValues = {
startDate: null,
student: sampleStudent,
workingHours: 40,
program: [],
}
export const InternshipTypeItem = ({ internshipType: type, ...props }: { internshipType: InternshipType } & HTMLProps<any>) => {
@ -86,9 +102,24 @@ export const InternshipTypeItem = ({ internshipType: type, ...props }: { interns
const InternshipProgramForm = () => {
const { t } = useTranslation();
const { values, handleBlur, setFieldValue, errors } = useFormikContext<InternshipFormValues>();
const [ selectedProgramEntries, setSelectedProgramEntries ] = useState<InternshipProgramEntry[]>(values.program);
const possibleProgramEntries = useSelector<AppState, InternshipProgramEntry[]>(state => state.edition.program);
const types = useInternshipTypes();
const handleProgramEntryChange = (entry: InternshipProgramEntry) => (ev: any) => {
if (ev.target.checked) {
setSelectedProgramEntries([ ...selectedProgramEntries, entry ]);
} else {
setSelectedProgramEntries(selectedProgramEntries.filter(cur => cur != entry));
}
}
useEffect(() => {
setFieldValue("program", selectedProgramEntries);
}, [ selectedProgramEntries ])
return (
<Grid container>
<Grid item md={ 4 }>
@ -108,6 +139,20 @@ const InternshipProgramForm = () => {
{/* <Field label={ t("forms.internship.fields.kind-other") } name="kindOther" fullWidth component={ TextFieldFormik } />*/}
{/* }*/}
{/*</Grid>*/}
<Grid item xs={ 12 }>
<FormGroup>
<FormLabel>{ t('forms.internship.fields.program', { count: 3 }) }</FormLabel>
{ possibleProgramEntries.map(
entry => <FormControlLabel
control={ <Checkbox /> }
checked={ selectedProgramEntries.includes(entry) }
onChange={ handleProgramEntryChange(entry) }
label={ entry.description }
key={ entry.id }
/>
) }
</FormGroup>
</Grid>
</Grid>
)
}
@ -206,6 +251,7 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
mentorLastName: internship.mentor?.surname || "",
mentorPhone: internship.mentor?.phone || "",
workingHours: 40,
program: internship.program || [],
}
},
reverseTransform(form: InternshipFormValues, context: InternshipConverterContext): Nullable<Internship> {
@ -235,6 +281,7 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
},
hours: form.hours ? form.hours : 0,
type: form.kind as InternshipType,
program: form.program,
}
}
}
@ -280,6 +327,7 @@ export const InternshipForm: React.FunctionComponent = () => {
city: Yup.string().required(t("validation.required")),
postalCode: Yup.string().required(t("validation.required")),
building: Yup.string().required(t("validation.required")),
program: Yup.array() as any,
// kindOther: Yup.string().when("kind", {
// is: (values: InternshipFormValues) => values?.kind === InternshipType.Other,
// then: Yup.string().required(t("validation.required"))

View File

@ -9,7 +9,7 @@ export const useCurrentStudent = () => useSelector<AppState, Student | null>(
)
export const useCurrentEdition = () => useSelector<AppState, Edition | null>(
state => state.edition && editionSerializationTransformer.reverseTransform(state.edition)
state => state.edition?.edition && editionSerializationTransformer.reverseTransform(state.edition.edition)
)
export const useDeadlines = () => {

View File

@ -4,7 +4,7 @@ import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
import "moment/locale/pl"
import "moment/locale/en-gb"
import moment, { isDuration, isMoment, unitOfTime } from "moment";
import moment, { isDuration, isMoment, unitOfTime } from "moment-timezone";
import { convertToRoman } from "@/utils/numbers";
const resources = {

View File

@ -7,7 +7,7 @@ import store, { persistor } from "@/state/store";
import { PersistGate } from "redux-persist/integration/react";
import { MuiThemeProvider as ThemeProvider, StylesProvider } from "@material-ui/core/styles";
import { MuiPickersUtilsProvider } from "@material-ui/pickers";
import moment, { Moment } from "moment";
import moment, { Moment } from "moment-timezone";
import { studentTheme } from "@/ui/theme";
import { BrowserRouter } from "react-router-dom";
import MomentUtils from "@date-io/moment";

View File

@ -25,11 +25,12 @@ export const loginToEdition = (id: string) => async (dispatch: AppDispatch) => {
token,
})
const edition = await api.edition.current();
const { edition, program } = await api.edition.current();
dispatch({
type: EditionActions.Set,
edition
edition,
program,
})
}

View File

@ -1,6 +1,6 @@
import React, { Dispatch, useEffect } from "react";
import { Page } from "@/pages/base";
import { Button, Container } from "@material-ui/core";
import { Button, CircularProgress, Container, Typography } from "@material-ui/core";
import { Action, StudentActions, useDispatch } from "@/state/actions";
import { Route, Switch, useHistory, useLocation, useRouteMatch } from "react-router-dom";
import { route } from "@/routing";
@ -10,6 +10,8 @@ import { AppState } from "@/state/reducer";
import api from "@/api";
import { UserActions } from "@/state/actions/user";
import { getAuthorizeUrl } from "@/api/user";
import { useTranslation } from "react-i18next";
import { Loading } from "@/components/loading";
const authorizeUser = (code?: string) => async (dispatch: Dispatch<Action>, getState: () => AppState): Promise<void> => {
const token = await api.user.login(code);
@ -32,6 +34,7 @@ export const UserLoginPage = () => {
const match = useRouteMatch();
const location = useLocation();
const query = new URLSearchParams(useLocation().search);
const { t } = useTranslation();
const handleSampleLogin = async () => {
await dispatch(authorizeUser());
@ -54,6 +57,8 @@ export const UserLoginPage = () => {
})();
}, [ match.path ]);
const inProgress = <Loading size="4rem" label={ t("login-in-progress") }/>
return <Page>
<Page.Header maxWidth="md">
<Page.Title>Zaloguj się</Page.Title>
@ -66,11 +71,13 @@ export const UserLoginPage = () => {
<Button fullWidth onClick={ handleSampleLogin } variant="contained" color="secondary">Zaloguj jako przykładowy student</Button>
</Container>
</Route>
<Route path={`${match.path}/pg`} render={
() => (window.location.href = getAuthorizeUrl())
} />
<Route path={`${match.path}/pg`} render={ () => {
window.location.href = getAuthorizeUrl()
return inProgress
} } />
<Route path={`${match.path}/check/pg`}>
Kod: { query.get("code") }
{ inProgress }
</Route>
</Switch>
</Container>

View File

@ -1,5 +1,5 @@
import { Edition } from "@/data/edition";
import moment from "moment";
import moment from "moment-timezone";
import { sampleCourse } from "@/provider/dummy/student";
export const sampleEdition: Edition = {

View File

@ -13,7 +13,7 @@ export const emptyInternship: Nullable<Internship> = {
endDate: null,
startDate: null,
type: null,
program: null,
program: [],
isAccepted: false,
lengthInWeeks: 0,
mentor: emptyMentor,

View File

@ -25,7 +25,6 @@ export const sampleCourse: Course = {
id: courseIdSequence(),
name: "Informatyka",
desiredSemesters: [6],
possibleProgramEntries: sampleProgramEntries,
}
export const sampleStudent: Student = {

View File

@ -1,7 +1,7 @@
import { Serializable, SerializationTransformer } from "@/serialization/types";
import { Edition } from "@/data/edition";
import { momentSerializationTransformer } from "@/serialization/moment";
import { Moment } from "moment";
import { Moment } from "moment-timezone";
export const editionSerializationTransformer: SerializationTransformer<Edition> = {
transform(subject: Edition, context?: unknown): Serializable<Edition> {

View File

@ -1,7 +1,7 @@
import { Internship, InternshipType } from "@/data";
import { Serializable, SerializationTransformer } from "@/serialization/types";
import { momentSerializationTransformer } from "@/serialization/moment";
import { Moment } from "moment";
import { Moment } from "moment-timezone";
export const internshipSerializationTransformer: SerializationTransformer<Internship> = {
transform: (internship: Internship): Serializable<Internship> => ({

View File

@ -1,7 +1,7 @@
import { SerializationTransformer } from "@/serialization/types";
import moment, { Moment } from "moment";
import moment, { Moment } from "moment-timezone";
export const momentSerializationTransformer: SerializationTransformer<Moment | null, string> = {
transform: (subject: Moment) => subject && subject.toISOString(),
transform: (subject: Moment) => subject && subject.clone().utc(false).add(subject.utcOffset(), 'minutes').toISOString(),
reverseTransform: (subject: string) => subject ? moment(subject) : null,
}

View File

@ -1,4 +1,4 @@
import { Moment } from "moment";
import { Moment } from "moment-timezone";
type Simplify<T> = string |
T extends string ? string :

View File

@ -1,5 +1,6 @@
import { Action } from "@/state/actions/base";
import { Edition } from "@/data/edition";
import { InternshipProgramEntry } from "@/data";
export enum EditionActions {
Set = 'SET_EDITION',
@ -7,6 +8,7 @@ export enum EditionActions {
export interface SetAction extends Action<EditionActions.Set> {
edition: Edition,
program: InternshipProgramEntry[],
}
export type EditionAction = SetAction;

View File

@ -2,15 +2,26 @@ import { Edition } from "@/data/edition";
import { EditionAction, EditionActions } from "@/state/actions/edition";
import { editionSerializationTransformer, Serializable } from "@/serialization";
import { LoginAction, LogoutAction, UserActions } from "@/state/actions";
import { InternshipProgramEntry } from "@/data";
export type EditionState = Serializable<Edition> | null;
export type EditionState = Serializable<{
edition: Edition | null,
program: InternshipProgramEntry[],
}>
const initialEditionState: EditionState = null;
const initialEditionState: EditionState = {
edition: null,
program: [],
};
const editionReducer = (state: EditionState = initialEditionState, action: EditionAction | LogoutAction | LoginAction): EditionState => {
switch (action.type) {
case EditionActions.Set:
return editionSerializationTransformer.transform(action.edition);
return {
...state,
edition: editionSerializationTransformer.transform(action.edition),
program: action.program,
};
case UserActions.Login:
case UserActions.Logout:
return initialEditionState;

View File

@ -22,4 +22,4 @@ export type AppState = ReturnType<typeof rootReducer>;
export default rootReducer;
export const isReady = (state: AppState) => !!state.edition;
export const isReady = (state: AppState) => !!(state.edition?.edition);

View File

@ -1,7 +1,7 @@
import { DeanApproval } from "@/data/deanApproval";
import { Action } from "@/state/actions";
import { momentSerializationTransformer } from "@/serialization";
import moment from "moment";
import moment from "moment-timezone";
import { ReceiveSubmissionApproveAction, ReceiveSubmissionDeclineAction, SubmissionAction } from "@/state/actions/submission";
export type SubmissionStatus = "draft" | "awaiting" | "accepted" | "declined";

View File

@ -20,3 +20,14 @@
.proposal__header:not(:first-child) {
margin-top: 1rem;
}
.loading-wrapper {
display: flex;
flex-direction: column;
align-items: center;
& > *:not(:last-child) {
margin-bottom: 1rem;
}
}

View File

@ -1,4 +1,4 @@
import { Moment } from "moment";
import { Moment } from "moment-timezone";
import Holidays from "date-holidays";
const holidays = new Holidays()

View File

@ -2,6 +2,7 @@
copyright: Wydział ETI Politechniki Gdańskiej © {{ date, YYYY }}
login: zaloguj się
login-in-progress: Logowanie w toku, proszę czekać...
logout: wyloguj się
logged-in-as: zalogowany jako <1>{{ name }}</1>
@ -79,6 +80,7 @@ forms:
country: Kraj
street: Ulica
building: Nr budynku
program: Program praktyki (wybierz {{ count }})
help:
weeks: Wartość wyliczana automatycznie
working-hours: Liczba godzin w tygodniu roboczym
@ -155,7 +157,8 @@ steps:
header: "Zgłoszenie praktyki"
info:
draft: >
Przed podjęciem praktyki należy ją zgłosić. (TODO)
Przed podjęciem praktyki należy ją zgłosić - w tym celu należy elektronicznie wypełnić formularz zgłoszenia
praktyki.
awaiting: >
Twoje zgłoszenie musi zostać zweryfikowane i zatwierdzone. Po weryfikacji zostaniesz poinformowany o
akceptacji bądź konieczności wprowadzenia zmian.
@ -171,7 +174,8 @@ steps:
info:
draft: >
W porozumieniu z firmą w której odbywają się praktyki należy sporządzić Indywidualny Plan Praktyk zgodnie z
załączonym szablonem a następnie wysłać go do weryfikacji. (TODO)
załączonym szablonem a następnie wysłać go do weryfikacji. Indywidualny Plan Praktyk musi zostać zatwierdzony
oraz podpisany przez Twojego zakłądowego opiekuna praktyki.
awaiting: >
Twój indywidualny program praktyki został poprawnie zapisany w systemie. Musi on jeszcze zostać zweryfikowany i
zatwierdzony. Po weryfikacji zostaniesz poinformowany o akceptacji bądź konieczności wprowadzenia zmian.