Merge branch 'feature/student_data_form'

This commit is contained in:
Kacper Donat 2020-11-12 17:34:42 +01:00
commit 5ccf8094c8
54 changed files with 715 additions and 247 deletions

View File

@ -31,6 +31,7 @@
"css-loader": "3.4.2",
"date-holidays": "^1.5.3",
"file-loader": "4.3.0",
"filesize": "^6.1.0",
"formik": "^2.1.5",
"formik-material-ui": "^3.0.0-alpha.0",
"html-webpack-plugin": "4.0.0-beta.11",
@ -39,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,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&amp;display=swap&amp;subset=latin,latin-ext" />
<title>Zgłoszenie praktyki studenckiej</title>
<base href="/">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&amp;display=swap&amp;subset=latin,latin-ext"/>
<title>Zgłoszenie praktyki studenckiej</title>
<base href="/">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<div id="modals"></div>
</body>
</html>

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,9 +3,10 @@ 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";
import { ProgramEntryDTO, programEntryDtoTransformer } from "@/api/dto/edition";
export enum SubmissionState {
Draft = "Draft",
@ -36,6 +37,7 @@ export interface InternshipRegistrationUpdate {
type: number,
mentor: MentorDTO,
hours: number,
subjects: string[],
}
export interface InternshipRegistrationDTO extends Identifiable {
@ -47,6 +49,7 @@ export interface InternshipRegistrationDTO extends Identifiable {
company: Company,
branchAddress: Office,
declaredHours: number,
subjects: { subject: ProgramEntryDTO }[],
}
export interface InternshipDocument extends Identifiable {
@ -65,8 +68,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 +83,7 @@ export const internshipRegistrationUpdateTransformer: OneWayTransformer<Nullable
branchOffice: subject?.office?.address as NewBranchOffice
},
hours: subject?.hours,
subjects: subject?.program?.map(program => program.id as string) || [],
}
}
}
@ -97,7 +101,7 @@ export const internshipRegistrationDtoTransformer: OneWayTransformer<InternshipR
hours: dto.declaredHours,
isAccepted: dto.state === SubmissionState.Accepted,
lengthInWeeks: 0,
program: [],
program: dto.subjects.map(subject => programEntryDtoTransformer.transform(subject.subject)),
intern: sampleStudent, // fixme
};
}

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

@ -1,14 +1,43 @@
import { InternshipInfoDTO, InternshipRegistrationUpdate } from "@/api/dto/internship-registration";
import { InternshipInfoDTO, InternshipRegistrationUpdate, SubmissionState } from "@/api/dto/internship-registration";
import { axios } from "@/api/index";
import { Nullable } from "@/helpers";
const INTERNSHIP_REGISTRATION_ENDPOINT = '/internshipRegistration';
const INTERNSHIP_ENDPOINT = '/internship';
export async function update(internship: Nullable<InternshipRegistrationUpdate>): Promise<boolean> {
await axios.put(INTERNSHIP_REGISTRATION_ENDPOINT, internship);
export type ValidationMessage = {
key: string;
parameters: { [name: string]: string },
}
return true;
export class ValidationError extends Error {
public readonly messages: ValidationMessage[];
constructor(messages: ValidationMessage[], message: string = "There were validation errors.") {
super(message);
Object.setPrototypeOf(this, ValidationError.prototype);
this.messages = messages;
}
}
interface UpdateResponse {
status: SubmissionState;
errors?: string[];
}
export async function update(internship: Nullable<InternshipRegistrationUpdate>): Promise<SubmissionState> {
const response = (await axios.put<UpdateResponse>(INTERNSHIP_REGISTRATION_ENDPOINT, internship)).data;
if (response.status == SubmissionState.Draft) {
throw new ValidationError(
response.errors?.map(
msg => ({ key: msg, parameters: {} })
) || []
);
}
return response.status;
}
export async function get(): Promise<InternshipInfoDTO> {

View File

@ -1,6 +1,7 @@
import { axios } from "@/api/index";
import { InternshipDocument } from "@/api/dto/internship-registration";
import { prepare } from "@/routing";
import { Identifiable } from "@/data";
export enum UploadType {
Ipp = "IppScan",
@ -8,6 +9,12 @@ export enum UploadType {
Insurance = "NnwInsurance",
}
export interface DocumentFileInfo extends Identifiable {
filename: string;
size: number;
mime: string;
}
const CREATE_DOCUMENT_ENDPOINT = '/document';
const DOCUMENT_UPLOAD_ENDPOINT = '/document/:id/scan';
@ -23,3 +30,8 @@ export async function upload(document: InternshipDocument, file: File) {
const response = await axios.put(prepare(DOCUMENT_UPLOAD_ENDPOINT, { id: document.id as string }), data);
return true;
}
export async function fileinfo(document: InternshipDocument): Promise<DocumentFileInfo> {
const response = await axios.get<DocumentFileInfo>(prepare(DOCUMENT_UPLOAD_ENDPOINT, { id: document.id as string }));
return response.data;
}

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);
const { t } = useTranslation();
const locale = useSelector<AppState, Locale>(state => getLocale(state.settings));
@ -73,27 +70,29 @@ function App() {
}, [ locale ])
return <>
<header className="header">
<div id="logo" className="header__logo">
<Link to={ route('home') }>
<img src="img/pg-logotyp.svg"/>
</Link>
</div>
<div className="header__nav">
<nav className="header__top">
<ul className="header__menu">
</ul>
<UserMenu className="header__user"/>
<div className="header__divider"/>
<LanguageSwitcher className="header__language-switcher"/>
</nav>
<nav className="header__bottom">
<ul className="header__menu header__menu--main">
<li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
<li><Link to="/regulamin">Regulamin</Link></li>
</ul>
</nav>
</div>
<header>
<Container className="header">
<div id="logo" className="header__logo">
<Link to={ route('home') }>
<img src="img/pg-logotyp.svg"/>
</Link>
</div>
<div className="header__nav">
<nav className="header__top">
<ul className="header__menu">
</ul>
<UserMenu className="header__user"/>
<div className="header__divider"/>
<LanguageSwitcher className="header__language-switcher"/>
</nav>
<nav className="header__bottom">
<ul className="header__menu header__menu--main">
<li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
<li><Link to="/regulations">Regulamin</Link></li>
</ul>
</nav>
</div>
</Container>
</header>
<main id="content">
{ <Switch>

View File

@ -0,0 +1,115 @@
import React, { useState } from "react";
import { Button, ButtonGroup, Dialog, DialogActions, DialogContent, DialogTitle, 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";
type AcceptanceActionsProps = {
onAccept: (comment?: string) => void;
onDiscard: (comment: string) => void;
label: string;
}
export function AcceptanceActions({ onAccept, onDiscard, label }: AcceptanceActionsProps) {
const { t } = useTranslation();
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);
}
const handleDiscardModalClose = () => {
setDiscardModelOpen(false);
}
const handleDiscardAction = () => {
setDiscardModelOpen(true);
}
const handleAcceptMenuOpen = (ev: React.MouseEvent<HTMLElement>) => {
setMenuAnchor(ev.currentTarget);
}
const handleAcceptMenuClose = () => {
setMenuAnchor(null);
}
const handleAcceptWithComment = () => {
setAcceptModelOpen(true);
setMenuAnchor(null);
}
const handleAcceptWithoutComment = () => {
onAccept();
}
return <>
<ButtonGroup color="primary" variant="contained">
<Button onClick={ handleAcceptWithoutComment } startIcon={ <StickerCheckOutline /> }>
{ t('accept-without-comments') }
</Button>
<Button size="small" onClick={ handleAcceptMenuOpen }><MenuDown /></Button>
</ButtonGroup>
<Menu open={ !!menuAnchor } anchorEl={ menuAnchor } onClose={ handleAcceptMenuClose }>
<MenuItem onClick={ handleAcceptWithoutComment }>{ t("accept-without-comments") }</MenuItem>
<MenuItem onClick={ handleAcceptWithComment }>{ t("accept-with-comments") }</MenuItem>
</Menu>
<Button onClick={ handleDiscardAction } color="secondary" startIcon={ <StickerRemoveOutline /> }>
{ t('discard') }
</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>
</>, document.getElementById("modals") as Element) }
</>
}

29
src/components/async.tsx Normal file
View File

@ -0,0 +1,29 @@
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>,
children: (value: TValue) => JSX.Element,
loading?: () => JSX.Element,
error?: (error: TError) => JSX.Element,
}
const defaultLoading = () => <Loading />;
const defaultError = (error: any) => <Alert severity="error">{ error.message }</Alert>;
export function Async<TValue, TError = any>(
{ async, children: render, loading = defaultLoading, error = defaultError }: AsyncProps<TValue, TError>
) {
if (async.isLoading || (!async.error && !async.value)) {
return loading();
}
if (typeof async.error !== "undefined") {
return error(async.error);
}
return render(async.value as TValue);
}

View File

@ -0,0 +1,93 @@
import { InternshipDocument } from "@/api/dto/internship-registration";
import { useAsync } from "@/hooks";
import { DocumentFileInfo } from "@/api/upload";
import React, { useCallback } from "react";
import api from "@/api";
import { Async } from "@/components/async";
import { Button, Grid, Paper, PaperProps, SvgIconProps, Theme, Typography } from "@material-ui/core";
import { makeStyles, createStyles } from "@material-ui/core/styles";
import filesize from "filesize";
import { Actions } from "@/components/actions";
import { useTranslation } from "react-i18next";
import { FileDownloadOutline, FileOutline, FileImageOutline, FilePdfOutline, FileWordOutline } from "mdi-material-ui";
import classNames from "classnames";
const useStyles = makeStyles((theme: Theme) => createStyles({
root: {
padding: theme.spacing(2),
backgroundColor: "#e9f0f5",
},
header: {
color: theme.palette.primary.dark,
},
download: {
color: theme.palette.primary.dark,
},
actions: {
marginTop: theme.spacing(2),
},
icon: {
fontSize: "6rem",
margin: "0 auto",
},
grid: {
display: "flex",
alignItems: "center",
},
iconColumn: {
flex: "0 1 auto",
marginRight: "1rem",
color: theme.palette.primary.dark + "af",
},
asideColumn: {
flex: "1 1 auto"
}
}))
export type FileInfoProps = {
document: InternshipDocument
} & PaperProps;
export type FileIconProps = {
mime: string;
} & SvgIconProps;
export function FileIcon({ mime, ...props }: FileIconProps) {
switch (true) {
case ["application/pdf", "application/x-pdf"].includes(mime):
return <FilePdfOutline {...props} />
case mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return <FileWordOutline {...props} />
case mime.startsWith("image/"):
return <FileImageOutline {...props} />
default:
return <FileOutline {...props} />
}
}
export const FileInfo = ({ document, ...props }: FileInfoProps) => {
const fileinfo = useAsync<DocumentFileInfo>(useCallback(() => api.upload.fileinfo(document), [document.id]));
const classes = useStyles();
const { t } = useTranslation();
return <Paper variant="outlined" { ...props } className={ classNames(classes.root, props.className) }>
<Async async={ fileinfo }>
{ fileinfo => <div className={ classes.grid }>
<div className={ classes.iconColumn }>
<FileIcon mime={ fileinfo.mime } className={ classes.icon } />
</div>
<aside className={ classes.asideColumn }>
<Typography variant="h5" className={ classes.header }>{ fileinfo.filename }</Typography>
<Typography variant="subtitle2">
{ filesize(fileinfo.size) } { fileinfo.mime }
</Typography>
<Actions className={ classes.actions }>
<Button className={ classes.download } startIcon={ <FileDownloadOutline /> }>{ t("download") }</Button>
</Actions>
</aside>
</div> }
</Async>
</Paper>
}

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

@ -1,12 +1,13 @@
import { Internship } from "@/data";
import React from "react";
import { Typography } from "@material-ui/core";
import { List, Typography, ListItem, ListItemIcon, ListItemText } 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";
import { Check, StickerCheck } from "mdi-material-ui";
export type ProposalPreviewProps = {
proposal: Internship;
@ -42,6 +43,16 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
<Typography className="proposal__primary">{ proposal.type.label.pl }</Typography>
</Section>
<Section>
<Label>{ t('internship.sections.program') }</Label>
<List>
{ proposal.program.map(subject => <ListItem key={ subject.id }>
<ListItemIcon><StickerCheck /></ListItemIcon>
<ListItemText>{ subject.description }</ListItemText>
</ListItem>) }
</List>
</Section>
<Section>
<Label>{ t('internship.sections.duration') }</Label>
<Typography className="proposal__primary">

View File

@ -1,10 +1,11 @@
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";
import { StepIcon } from "@/components/stepIcon";
type StepProps = StepperStepProps & {
notBefore?: Moment;
until?: Moment;
completedOn?: Moment;
label: string;
@ -17,7 +18,7 @@ type StepProps = StepperStepProps & {
const now = moment();
export const Step = (props: StepProps) => {
const { until, label, completedOn, children, completed = false, declined = false, waiting = false, state = null, ...rest } = props;
const { until, notBefore, label, completedOn, children, completed = false, declined = false, waiting = false, state = null, ...rest } = props;
const { t } = useTranslation();
const isLate = useMemo(() => until?.isBefore(completedOn || now), [completedOn, until]);
@ -26,18 +27,22 @@ export const Step = (props: StepProps) => {
return <StepperStep { ...rest } completed={ completed }>
<StepLabel error={ declined } StepIconComponent={ StepIcon } StepIconProps={{ ...props, waiting } as any}>
{ label }
{ until && <Box>
{ state && <>
<Typography variant="subtitle2" display="inline">{ state }</Typography>
<Box>
{ state && <Typography variant="subtitle2" display="inline">{ state }</Typography> }
{ notBefore &&
<Typography variant="subtitle2" color="textSecondary" display="inline">
{ t('not-before', { date: notBefore }) }
</Typography> }
{ until && <>
<Typography variant="subtitle2" display="inline" color="textSecondary"> </Typography>
<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 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>
</Box> }
</Box>
</StepLabel>
{ children && <StepContent>{ children }</StepContent> }
</StepperStep>

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

@ -194,7 +194,7 @@ export const CompanyForm: React.FunctionComponent = () => {
return (
<>
<Grid container>
<Grid item>
<Grid item xs={12}>
<Autocomplete options={ companies }
getOptionLabel={ option => typeof option === "string" ? option : option.name }
renderOption={ company => <CompanyItem company={ company }/> }

View File

@ -1,16 +1,28 @@
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, useRef, useState } from "react";
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
FormControlLabel,
FormGroup,
Grid,
TextField,
Typography
} 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 } from "moment-timezone";
import { computeWorkingHours } from "@/utils/date";
import { Autocomplete } from "@material-ui/lab";
import { Alert, AlertTitle, Autocomplete } from "@material-ui/lab";
import { emptyInternship } from "@/provider/dummy/internship";
import { useDispatch } from "@/state/actions";
import { InternshipProposalActions, useDispatch } from "@/state/actions";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
@ -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 { ValidationError, ValidationMessage } from "@/api/internship";
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.find(cur => entry.id == cur.id) !== undefined }
onChange={ handleProgramEntryChange(entry) }
label={ entry.description }
key={ entry.id }
/>
) }
</FormGroup>
</Grid>
</Grid>
)
}
@ -137,19 +182,22 @@ const InternshipDurationForm = () => {
return (
<Grid container>
<Grid item md={ 6 }>
<DatePicker value={ startDate } onChange={ value => setFieldValue("startDate", value) }
format="DD MMMM yyyy"
<DatePicker value={ startDate }
onChange={ value => setFieldValue("startDate", value) }
format="DD.MM.yyyy"
disableToolbar fullWidth
variant="inline" label={ t("forms.internship.fields.start-date") }
minDate={ moment() }
variant="inline"
label={ t("forms.internship.fields.start-date") }
/>
</Grid>
<Grid item md={ 6 }>
<DatePicker value={ endDate } onChange={ value => setFieldValue("endDate", value) }
format="DD MMMM yyyy"
<DatePicker value={ endDate }
onChange={ value => setFieldValue("endDate", value) }
format="DD.MM.yyyy"
disableToolbar fullWidth
variant="inline" label={ t("forms.internship.fields.end-date") }
minDate={ startDate || moment() }
variant="inline"
label={ t("forms.internship.fields.end-date") }
minDate={ startDate }
/>
</Grid>
<Grid item md={ 4 }>
@ -207,6 +255,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> {
@ -236,12 +285,18 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
},
hours: form.hours ? form.hours : 0,
type: form.kind as InternshipType,
program: form.program,
}
}
}
export const InternshipForm: React.FunctionComponent = () => {
const student = useCurrentStudent();
const student = useCurrentStudent();
const history = useHistory();
const root = useRef<HTMLElement>(null);
const dispatch = useDispatch();
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const initialInternship = useSelector<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || {
...emptyInternship,
@ -252,12 +307,8 @@ export const InternshipForm: React.FunctionComponent = () => {
});
const edition = useCurrentEdition();
const { t } = useTranslation();
const dispatch = useDispatch();
const history = useHistory();
const [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false);
const validationSchema = Yup.object<Partial<InternshipFormValues>>({
@ -285,6 +336,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"))
@ -293,17 +345,25 @@ export const InternshipForm: React.FunctionComponent = () => {
const values = converter.transform(initialInternship);
const handleSubmit = (values: InternshipFormValues) => {
const handleSubmit = async (values: InternshipFormValues) => {
setConfirmDialogOpen(false);
const internship = converter.reverseTransform(values, { internship: initialInternship as Internship });
const update = internshipRegistrationUpdateTransformer.transform(internship);
console.log(update);
try {
await api.internship.update(update);
dispatch({ type: InternshipProposalActions.Send });
api.internship.update(update);
// history.push(route("home"))
history.push(route("home"))
} catch (error) {
if (error instanceof ValidationError) {
setErrors(error.messages);
root.current?.scrollIntoView({ behavior: "smooth" })
} else {
throw error;
}
}
}
const InnerForm = () => {
@ -323,10 +383,16 @@ export const InternshipForm: React.FunctionComponent = () => {
setConfirmDialogOpen(false);
}
return <Form>
return <Form ref={ root as any }>
{ errors.length > 0 && <Alert severity="warning">
<AlertTitle>{ t('internship.validation.has-errors') }</AlertTitle>
<ul style={{ paddingLeft: 0 }}>
{ errors.map(message => <li key={ message.key }>{ t(`internship.validation.${message.key}`, message.parameters) }</li>) }
</ul>
</Alert> }
<Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography>
<StudentForm />
<Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography>
<Typography variant="h3" className="section-header">{ t('internship.sections.kind') }</Typography>
<InternshipProgramForm />
<Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography>
<InternshipDurationForm />

View File

@ -41,19 +41,19 @@ export const PlanForm = () => {
}
return <Grid container>
<Grid item>
<Grid item xs={12}>
<Typography variant="body1" component="p">{ t('forms.plan.instructions') }</Typography>
</Grid>
<Grid item>
<Grid item xs={12}>
<Button href="https://eti.pg.edu.pl/documents/611675/100028367/indywidualny%20program%20praktyk" startIcon={ <DescriptionIcon /> }>
{ t('steps.plan.template') }
</Button>
</Grid>
<Grid item>
<Grid item xs={12}>
<DropzoneArea acceptedFiles={["image/*", "application/pdf"]} filesLimit={ 1 } dropzoneText={ t("dropzone") } onChange={ files => setFile(files[0]) }/>
<FormHelperText>{ t('forms.plan.dropzone-help') }</FormHelperText>
</Grid>
<Grid item>
<Grid item xs={12}>
<Actions>
<Button variant="contained" color="primary" onClick={ handleSubmit }>
{ t('confirm') }

View File

@ -35,7 +35,7 @@ export const StudentForm = () => {
<Grid item md={3}>
<TextField label={ t("forms.internship.fields.semester") } value={ student.semester || "" } disabled fullWidth/>
</Grid>
<Grid item>
<Grid item xs={12}>
<Alert severity="warning" action={ <Button color="inherit" size="small">skontaktuj się z opiekunem</Button> }>
Powyższe dane nie poprawne?
</Alert>

View File

@ -10,6 +10,8 @@ import { Actions } from "@/components";
import { Nullable } from "@/helpers";
import * as Yup from "yup";
import { StudentActions, useDispatch } from "@/state/actions";
import { route } from "@/routing";
import { useHistory } from "react-router-dom";
interface StudentFormValues {
firstName: string;
@ -48,6 +50,7 @@ const studentToFormValuesTransformer: Transformer<Nullable<Student>, StudentForm
export const StudentForm = ({ student }: StudentFormProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const history = useHistory();
const validationSchema = useMemo(() => Yup.object<StudentFormValues>({
semester: Yup.number().required().min(1).max(10),
@ -71,6 +74,8 @@ export const StudentForm = ({ student }: StudentFormProps) => {
type: StudentActions.Set,
student: updated,
})
history.push(route("home"));
}

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

@ -5,6 +5,8 @@ import React, { useMemo } from "react";
import { route } from "@/routing";
import { useAsync } from "@/hooks";
import api from "@/api";
import { Loading } from "@/components/loading";
import { useSpacing } from "@/styles";
export const FallbackPage = () => {
const location = useLocation();
@ -13,23 +15,23 @@ export const FallbackPage = () => {
const { isLoading, value, error } = useAsync(promise);
console.log({ isLoading, value, error, location });
if (isLoading) {
return <CircularProgress />
return <div style={{ marginTop: "2rem" }}><Loading size="8rem"/></div>
}
if (error) {
return <Page title="Strona nie została znaleziona">
<Container>
<Typography variant="h1">404</Typography>
<Typography variant="h2">Strona nie została znaleziona</Typography>
<Box my={4}>
<Typography variant="h1">404</Typography>
<Typography variant="h2">Strona nie została znaleziona</Typography>
<Box my={ 4 }>
<Divider variant="fullWidth"/>
<Box my={ 4 }>
<Divider variant="fullWidth"/>
</Box>
<Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
</Box>
<Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
</Container>
</Page>
}

View File

@ -28,6 +28,7 @@ import { Actions } from "@/components";
import { InternshipProposalActions, useDispatch } from "@/state/actions";
import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui/index";
import { useVerticalSpacing } from "@/styles";
import { AcceptanceActions } from "@/components/acceptance-action";
export const InternshipProposalFormPage = () => {
const { t } = useTranslation();
@ -54,49 +55,13 @@ export const InternshipProposalPreviewPage = () => {
const dispatch = useDispatch();
const history = useHistory();
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 handleAccept = () => {
dispatch({ type: InternshipProposalActions.Approve, comment });
const handleAccept = (comment?: string) => {
dispatch({ type: InternshipProposalActions.Approve, comment: comment || null });
history.push(route("home"));
}
const handleDiscard = () => {
dispatch({ type: InternshipProposalActions.Decline, comment });
history.push(route("home"));
}
const handleAcceptModalClose = () => {
setAcceptModelOpen(false);
}
const handleDiscardModalClose = () => {
setDiscardModelOpen(false);
}
const handleDiscardAction = () => {
setDiscardModelOpen(true);
}
const handleAcceptMenuOpen = (ev: React.MouseEvent<HTMLElement>) => {
setMenuAnchor(ev.currentTarget);
}
const handleAcceptMenuClose = () => {
setMenuAnchor(null);
}
const handleAcceptWithComment = () => {
setAcceptModelOpen(true);
setMenuAnchor(null);
}
const handleAcceptWithoutComment = () => {
dispatch({ type: InternshipProposalActions.Approve, comment: null });
const handleDiscard = (comment: string) => {
dispatch({ type: InternshipProposalActions.Decline, comment: comment });
history.push(route("home"));
}
@ -115,59 +80,13 @@ export const InternshipProposalPreviewPage = () => {
{ proposal && <ProposalPreview proposal={ proposal } /> }
<Actions>
<ButtonGroup color="primary" variant="contained">
<Button onClick={ handleAcceptWithoutComment } startIcon={ <StickerCheckOutline /> }>
{ t('accept-without-comments') }
</Button>
<Button size="small" onClick={ handleAcceptMenuOpen }><MenuDown /></Button>
</ButtonGroup>
<Menu open={ !!menuAnchor } anchorEl={ menuAnchor } onClose={ handleAcceptMenuClose }>
<MenuItem onClick={ handleAcceptWithoutComment }>{ t("accept-without-comments") }</MenuItem>
<MenuItem onClick={ handleAcceptWithComment }>{ t("accept-with-comments") }</MenuItem>
</Menu>
<Button onClick={ handleDiscardAction } color="secondary" startIcon={ <StickerRemoveOutline /> }>
{ t('discard') }
</Button>
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="internship" />
<Button component={ RouterLink } to={ route("home") }>
{ t('go-back') }
</Button>
</Actions>
</Container>
<Dialog open={ isDiscardModalOpen } onClose={ handleDiscardModalClose } maxWidth="md">
<DialogTitle>{ t("internship.discard.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t("internship.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("internship.accept.title") }</DialogTitle>
<DialogContent className={ classes.root }>
<Typography variant="body1">{ t("internship.accept.info") }</Typography>
<TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") }/>
<DialogActions>
<Button onClick={ handleAcceptModalClose }>
{ t('cancel') }
</Button>
<Button onClick={ handleAccept } color="primary" variant="contained">
{ t('confirm') }
</Button>
</DialogActions>
</DialogContent>
</Dialog>
</Page>
}

View File

@ -13,7 +13,7 @@ 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 { useDeadlines } from "@/hooks";
import { useCurrentEdition, useDeadlines } from "@/hooks";
import api from "@/api";
import { AppDispatch, InternshipPlanActions, InternshipProposalActions, useDispatch } from "@/state/actions";
import { internshipRegistrationDtoTransformer } from "@/api/dto/internship-registration";
@ -36,6 +36,10 @@ export const updateInternshipInfo = async (dispatch: AppDispatch) => {
document: plan,
state: plan.state,
})
} else {
dispatch({
type: InternshipPlanActions.Reset,
})
}
}
@ -47,6 +51,7 @@ export const MainPage = () => {
const deadlines = useDeadlines();
const insurance = useSelector<AppState, InsuranceState>(root => root.insurance);
const dispatch = useDispatch();
const edition = useCurrentEdition();
useEffect(() => {
dispatch(updateInternshipInfo);
@ -64,13 +69,15 @@ export const MainPage = () => {
if (insurance.required)
yield <InsuranceStep key="insurance"/>;
yield <Step label={ t('steps.report.header') } until={ deadlines.report } key="report"/>
yield <Step label={ t('steps.report.header') } until={ deadlines.report } notBefore={ edition?.reportingStart } key="report"/>
yield <Step label={ t('steps.grade.header') } key="grade"/>
}
return <Page my={ 6 }>
return <Page>
<Page.Header>
<Page.Title>{ t("pages.my-internship.header") }</Page.Title>
</Page.Header>
<Container>
<Typography variant="h2">{ t("pages.my-internship.header") }</Typography>
<Stepper orientation="vertical" nonLinear>
{ Array.from(getSteps()) }
</Stepper>

View File

@ -5,24 +5,29 @@ import { useTranslation } from "react-i18next";
import { Box, Button, ButtonProps, StepProps } from "@material-ui/core";
import { FileDownloadOutline, FileUploadOutline } from "mdi-material-ui/index";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { Link as RouterLink, useHistory } from "react-router-dom";
import { Actions, Step } from "@/components";
import React, { HTMLProps } from "react";
import { Alert, AlertTitle } from "@material-ui/lab";
import { ContactAction, Status } from "@/pages/steps/common";
import { Description as DescriptionIcon } from "@material-ui/icons";
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 = () => {
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.plan));
const { t } = useTranslation();
const dispatch = useDispatch();
const history = useHistory();
const ReviewAction = (props: ButtonProps) =>
<Button startIcon={ <FileDownloadOutline /> } color="primary" { ...props }>{ t('steps.plan.download') }</Button>
const FormAction = ({ children = t('steps.plan.form'), ...props }: ButtonProps) =>
<Button to={ route("internship_plan") } variant="contained" color="primary" component={ RouterLink } startIcon={ <FileUploadOutline /> }>
{ t('steps.plan.submit') }
const FormAction = ({ children = t('steps.plan.submit'), ...props }: ButtonProps) =>
<Button to={ route("internship_plan") } variant="contained" color="primary" component={ RouterLink } startIcon={ <FileUploadOutline /> } { ...props as any }>
{ children }
</Button>
const TemplateAction = (props: ButtonProps) =>
@ -30,20 +35,28 @@ const PlanActions = () => {
{ t('steps.plan.template') }
</Button>
const handleAccept = (comment?: string) => {
dispatch({ type: InternshipPlanActions.Approve, comment: comment || null });
history.push(route("home"));
}
const handleDiscard = (comment: string) => {
dispatch({ type: InternshipPlanActions.Decline, comment: comment });
history.push(route("home"));
}
switch (status) {
case "awaiting":
return <Actions>
<ReviewAction />
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="plan" />
</Actions>
case "accepted":
return <Actions>
<ReviewAction/>
<FormAction variant="outlined" color="secondary">{ t('send-again') }</FormAction>
</Actions>
case "declined":
return <Actions>
<FormAction>{ t('fix-errors') }</FormAction>
<ReviewAction />
<TemplateAction />
<ContactAction/>
</Actions>
@ -72,6 +85,8 @@ export const PlanStep = (props: StepProps) => {
const { t } = useTranslation();
const submission = useSelector<AppState, SubmissionState>(state => state.plan);
const document = useSelector<AppState, InternshipDocument>(state => state.plan.document as InternshipDocument);
const spacing = useSpacing(2);
const status = getSubmissionStatus(submission);
const deadlines = useDeadlines();
@ -83,10 +98,13 @@ export const PlanStep = (props: StepProps) => {
active={ true } completed={ sent } declined={ declined } waiting={ status == "awaiting" }
until={ deadlines.proposal }
state={ <Status submission={ submission } /> }>
<p>{ t(`steps.plan.info.${ status }`) }</p>
<div className={ spacing.vertical }>
<p>{ t(`steps.plan.info.${ status }`) }</p>
{ comment && <Box pb={ 2 }><PlanComment/></Box> }
{ comment && <Box pb={ 2 }><PlanComment/></Box> }
{ document && <FileInfo document={ document } /> }
<PlanActions/>
<PlanActions/>
</div>
</Step>;
}

View File

@ -30,7 +30,7 @@ export const StudentStep = (props: StepProps) => {
</> : <>
<p>{ t('steps.personal-data.all-filled') }</p>
<Actions>
<Button to={ route("user_profile") } variant="outlined" color="primary" component={ RouterLink } startIcon={ <AccountDetails /> }>
<Button to={ route("user_profile") } component={ RouterLink } startIcon={ <AccountDetails /> }>
{ t('steps.personal-data.actions.info') }
</Button>
</Actions>

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

@ -7,6 +7,7 @@ import {
} from "@/state/actions/submission";
import { InternshipDocument, SubmissionState } from "@/api/dto/internship-registration";
import { Action } from "@/state/actions/base";
export enum InternshipPlanActions {
Send = "SEND_PLAN",
@ -14,8 +15,11 @@ export enum InternshipPlanActions {
Approve = "RECEIVE_PLAN_APPROVE",
Decline = "RECEIVE_PLAN_DECLINE",
Receive = "RECEIVE_PLAN_STATE",
Reset = "RESET_PLAN",
}
export interface ResetPlanAction extends Action<InternshipPlanActions.Reset> {}
export interface SendPlanAction extends SendSubmissionAction<InternshipPlanActions.Send> {
document: InternshipDocument;
}
@ -40,4 +44,6 @@ export type InternshipPlanAction
| SavePlanAction
| ReceivePlanApproveAction
| ReceivePlanDeclineAction
| ReceivePlanUpdateAction;
| ReceivePlanUpdateAction
| ResetPlanAction
;

View File

@ -17,7 +17,6 @@ export enum InternshipProposalActions {
}
export interface SendProposalAction extends SendSubmissionAction<InternshipProposalActions.Send> {
internship: Internship;
}
export interface ReceiveProposalApproveAction extends ReceiveSubmissionApproveAction<InternshipProposalActions.Approve> {

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,4 +1,4 @@
import { InternshipPlanAction, InternshipPlanActions, InternshipProposalActions } from "@/state/actions";
import { InternshipPlanAction, InternshipPlanActions } from "@/state/actions";
import { Serializable } from "@/serialization/types";
import {
createSubmissionReducer,
@ -10,6 +10,7 @@ import {
import { Reducer } from "react";
import { SubmissionAction } from "@/state/actions/submission";
import { InternshipDocument, SubmissionState as ApiSubmissionState } from "@/api/dto/internship-registration";
import { Api } from "mdi-material-ui";
export type InternshipPlanState = SubmissionState & MayRequireDeanApproval & {
document: Serializable<InternshipDocument> | null;
@ -40,9 +41,14 @@ const internshipPlanReducer = (state: InternshipPlanState = defaultInternshipPla
document: action.document,
}
case InternshipPlanActions.Receive:
if (state.overwritten) {
return state;
}
return {
...state,
accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [
ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected,
@ -51,6 +57,9 @@ const internshipPlanReducer = (state: InternshipPlanState = defaultInternshipPla
document: action.document,
}
case InternshipPlanActions.Reset:
return defaultInternshipPlanState;
default:
return state;
}

View File

@ -42,12 +42,16 @@ const internshipProposalReducer = (state: InternshipProposalState = defaultInter
case InternshipProposalActions.Send:
return {
...state,
proposal: internshipSerializationTransformer.transform(action.internship),
}
case InternshipProposalActions.Receive:
if (state.overwritten) {
return state;
}
return {
...state,
accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [
ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected,

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";
@ -12,6 +12,7 @@ export type SubmissionState = {
sentOn: string | null;
declined: boolean;
comment: string | null;
overwritten: boolean;
}
export type MayRequireDeanApproval = {
@ -24,6 +25,7 @@ export const defaultSubmissionState: SubmissionState = {
sentOn: null,
declined: false,
comment: null,
overwritten: false,
}
export const defaultDeanApprovalsState: MayRequireDeanApproval = {
@ -56,6 +58,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: true,
declined: false,
comment: (action as ReceiveSubmissionApproveAction<any>).comment,
overwritten: true,
}
case SubmissionAction.Decline:
return {
@ -63,6 +66,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: false,
declined: true,
comment: (action as ReceiveSubmissionDeclineAction<any>).comment,
overwritten: true,
}
case SubmissionAction.Send:
return {
@ -72,6 +76,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: false,
declined: false,
comment: null,
overwritten: false,
}
default:
return state;

View File

@ -1,9 +1,12 @@
@import "variables";
.header {
height: 110px;
header {
background: $main;
}
.header {
display: flex;
height: 110px;
color: white;
}

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

@ -17,3 +17,16 @@ export const useHorizontalSpacing = makeStyles(theme => createStyles({
}
}
}))
export const useSpacing = makeStyles(theme => createStyles({
horizontal: {
"& > *:not(:last-child)": {
marginRight: (spacing: number = defaultSpacing) => theme.spacing(spacing)
}
},
vertical: {
"& > *:not(:last-child)": {
marginBottom: (spacing: number = defaultSpacing) => theme.spacing(spacing)
}
}
}))

View File

@ -4,7 +4,6 @@ export const studentTheme = responsiveFontSizes(createMuiTheme({
props: {
MuiGrid: {
spacing: 3,
xs: 12,
},
MuiContainer: {
maxWidth: "md"

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,10 +2,12 @@
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>
until: do {{ date, DD MMMM YYYY }}
not-before: od {{ date, DD MMMM YYYY }}
late: '{{ by, humanize }} spóźnienia'
left: jeszcze {{ left, humanize }}
@ -79,6 +81,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
@ -112,6 +115,11 @@ submission:
draft: "wersja robocza"
internship:
validation:
has-errors: "W formularzu zostały znalezione błędy"
error:
declared_hours:
empty: "Brak zadeklarowanej długości praktyki."
intern:
semester: semestr {{ semester, roman }}
album: "numer albumu {{ album }}"
@ -133,6 +141,7 @@ internship:
place: "Miejsce odbywania praktyki"
kind: "Rodzaj i program praktyki"
mentor: "Zakładowy opiekun praktyki"
program: "Realizowane punkty programu praktyki"
discard:
title: "Odrzuć zgłoszenie praktyki"
info: "Poniższa informacja zostanie przekazana praktykantowi w celu poprawy zgłoszenia."
@ -140,6 +149,14 @@ internship:
title: "Zaakceptuj zgłoszenie praktyki"
info: "Poniższa informacja zostanie przekazana praktykantowi."
plan:
discard:
title: "Odrzuć indywidualny program praktyki"
info: "Poniższa informacja zostanie przekazana praktykantowi w celu poprawy zgłoszenia."
accept:
title: "Zaakceptuj indywidualny program praktyki"
info: "Poniższa informacja zostanie przekazana praktykantowi."
steps:
personal-data:
header: "Uzupełnienie danych"
@ -155,7 +172,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 +189,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.
@ -200,3 +219,4 @@ validation:
minimum-hours: "Minimalna liczba godzin wynosi {{ hours }}"
contact-coordinator: "Skontaktuj się z koordynatorem"
download: "pobierz"

View File

@ -3858,6 +3858,11 @@ filesize@6.0.1:
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.0.1.tgz#f850b509909c7c86f7e450ea19006c31c2ed3d2f"
integrity sha512-u4AYWPgbI5GBhs6id1KdImZWn5yfyFrrQ8OWZdN7ZMfA8Bf4HcO0BGo9bmUIEV8yrp8I1xVfJ/dn90GtFNNJcg==
filesize@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==
fill-range@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"