Merge pull request 'feature/management' (#19) from feature/management into master

This commit is contained in:
Kacper Donat 2020-11-19 17:49:59 +01:00
commit 8e1fe48393
65 changed files with 2671 additions and 72 deletions

7
deploy-dev.sh Normal file
View File

@ -0,0 +1,7 @@
BASEDIR=$(dirname "$0")
npx webpack --mode production --progress || exit $?
rsync -azv $BASEDIR/public/* system-praktyk@kadet.net:~/dev/front
rsync -azv $BASEDIR/build/* system-praktyk@kadet.net:~/dev/front

7
deploy-stg.sh Normal file
View File

@ -0,0 +1,7 @@
BASEDIR=$(dirname "$0")
npx webpack --mode production --progress || exit $?
rsync -azv $BASEDIR/public/* system-praktyk@kadet.net:~/stg/front
rsync -azv $BASEDIR/build/* system-praktyk@kadet.net:~/stg/front

View File

@ -5,11 +5,14 @@
"dependencies": {
"@babel/core": "7.9.0",
"@babel/preset-typescript": "^7.10.1",
"@ckeditor/ckeditor5-build-classic": "^23.1.0",
"@ckeditor/ckeditor5-react": "^3.0.0",
"@date-io/moment": "^1.3.13",
"@material-ui/core": "^4.10.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.55",
"@material-ui/pickers": "^3.2.10",
"@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.10",
"@types/node": "^12.0.0",
"@types/react": "^16.9.0",
@ -38,9 +41,9 @@
"i18next": "^19.6.0",
"i18next-browser-languagedetector": "^5.0.0",
"jsonwebtoken": "^8.5.1",
"material-table": "^1.69.1",
"material-ui-dropzone": "^3.3.0",
"mdi-material-ui": "^6.17.0",
"moment-timezone": "^2.26.0",
"moment-timezone": "^0.5.31",
"node-sass": "^4.14.1",
"optimize-css-assets-webpack-plugin": "5.0.3",

53
public/img/pg-logo.svg Normal file
View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="prefix__PGLogotyp"
viewBox="0 0 143 64"
version="1.1">
<metadata
id="metadata935">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs905">
<style
id="style903">.prefix__cls-1{fill:#fff}</style>
</defs>
<path
id="prefix__Path_108"
d="m 30.446,9.626 -3.339,2.357 1.375,1.866 2.652,-1.964 h 3.045 l 0.982,-7.17 -6.777,-1.277 -0.687,3.732 2.259,0.393 0.295,-1.473 2.357,0.491 -0.393,3.045 z"
class="prefix__cls-1"
data-name="Path 108" />
<path
id="prefix__Path_109"
d="m 111.866,11.884 2.652,1.964 1.375,-1.866 -3.241,-2.357 h -1.866 l -0.393,-3.045 2.455,-0.491 0.2,1.473 2.259,-0.393 -0.593,-3.731 -6.875,1.277 0.982,7.17 z"
class="prefix__cls-1"
data-name="Path 109" />
<path
id="prefix__Path_110"
d="m 130.036,44.884 h -2.259 v 2.259 h 7.464 l 2.159,3.045 -5.4,10.705 h -7.857 l 1.67,-3.339 h 3.241 l 3.536,-7.17 h -8.741 V 39.777 l -9.92,-7.464 2.161,-2.848 -1.866,-1.375 -3.339,4.42 v 2.554 l -11.2,2.455 -3.437,-0.982 V 34.571 H 93.991 V 49.4 L 71.4,58.045 48.813,49.4 V 32.214 h -2.259 v 1.964 l -3.634,1.08 h -10.9 v -2.847 l -3.339,-4.42 -1.866,1.375 2.161,2.848 -9.92,7.563 v 10.607 h -8.743 l 3.536,7.17 h 3.241 l 1.67,3.339 H 10.9 L 5.6,50.286 7.761,47.241 h 7.464 V 44.982 H 12.964 V 35.75 l 4.321,-3.241 V 27.795 L 21.8,24.357 20.523,22.589 27.3,17.482 35.259,21.8 v 8.45 L 44.1,31.527 45.473,27.5 h 3.536 V 8.643 h 45.179 v 21.214 h 3.536 l 1.473,4.321 8.741,-4.125 V 21.8 l 7.955,-4.321 6.777,5.107 -1.375,1.768 4.518,3.438 v 4.714 l 4.321,3.241 v 9.134 z m -86.136,-27.5 1.473,-4.42 h 1.179 v 8.054 H 44 l -6.58,4.911 v -5.3 z m -6.384,10.9 z M 46.65,25.338 H 43.9 l -1.277,3.732 -4.616,-0.589 6.875,-5.107 h 1.768 z m 58.732,3.339 -5.009,2.357 L 99.2,27.6 h -2.75 v -4.323 h 1.768 z m -7.759,-15.714 1.473,4.42 6.384,3.143 v 5.3 L 98.9,20.92 h -2.554 v -8.054 h 1.277 z m 39.977,12.573 -1.377,1.864 3.634,2.75 3.143,-4.125 v -1.864 l -6.384,-4.714 -5.009,6.482 v 3.536 l 4.223,3.143 v 4.518 l -7.857,-5.893 v -4.715 l -4.714,-3.536 1.375,-1.768 -6.679,-5.009 2.063,-1.08 5.893,4.42 -1.375,1.768 4.223,3.241 1.375,-1.866 -4.223,-3.143 1.375,-1.768 -5.107,-3.83 v -2.652 l 6.286,4.714 -1.375,1.768 3.438,2.554 1.375,-1.866 -3.437,-2.554 1.375,-1.768 -7.761,-5.795 V 5.795 l 9.036,6.777 -1.375,1.768 2.554,1.964 1.375,-1.866 -2.554,-1.964 1.375,-1.768 -10.214,-7.76 V 0 h -2.259 v 2.455 l -3.634,0.688 0.393,2.259 3.241,-0.589 v 7.857 l -6.187,3.339 -2.848,-2.161 -1.375,1.866 1.964,1.473 -3.732,2.063 -6.973,-3.437 L 99.1,10.705 H 96.25 V 6.384 h -49.6 v 4.321 H 43.8 L 42.032,15.812 35.063,19.25 31.33,17.188 33.294,15.715 31.92,13.848 29.072,16.009 22.884,12.67 V 4.813 L 26.125,5.402 26.518,3.143 22.884,2.456 V 0 h -2.259 v 2.946 l -10.214,7.854 1.375,1.768 -2.554,1.964 1.375,1.868 2.554,-1.964 -1.375,-1.766 9.036,-6.777 v 2.652 l -7.759,5.795 1.375,1.768 -3.438,2.553 1.375,1.866 3.438,-2.554 -1.375,-1.768 6.286,-4.714 v 2.652 l -5.107,3.83 1.375,1.768 -4.223,3.143 1.375,1.67 4.223,-3.241 -1.277,-1.67 5.893,-4.42 2.063,1.08 -6.679,5.009 1.375,1.768 -4.714,3.536 V 31.33 L 7.17,37.223 v -4.518 l 4.223,-3.143 V 25.83 L 6.384,19.348 0,24.063 v 1.866 L 3.143,30.054 6.777,27.304 5.5,25.536 3.634,26.911 2.357,25.143 5.893,22.491 9.036,26.616 v 1.768 l -4.223,3.143 v 6.482 l 1.866,2.455 4.027,-3.045 v 7.563 H 6.58 L 2.946,50.089 9.429,63.25 h 11.785 v -2.357 l -2.75,-5.6 h -3.241 l -1.277,-2.554 h 7.268 V 40.955 L 23.669,39.187 33,43.9 v 6.286 l -2.161,3.144 2.061,4.224 h 3.241 l 1.67,3.339 h -7.856 l -4.027,-8.152 1.964,-2.848 v -5.009 h -4.321 v 2.259 h 2.063 v 2.063 l -2.357,3.339 5.3,10.607 h 11.789 v -2.357 l -2.75,-5.6 h -3.241 l -0.884,-1.768 1.866,-2.652 V 42.33 l -9.527,-4.91 3.929,-2.946 v 2.848 H 43.411 L 46.652,36.34 V 50.679 L 71.5,60.205 96.348,50.678 V 38.795 l 3.438,0.982 13.554,-2.946 V 34.67 l 3.929,2.946 -9.527,4.911 v 8.446 l 1.866,2.652 -0.884,1.768 h -3.241 l -2.75,5.6 v 2.357 h 11.786 l 5.3,-10.607 -2.355,-3.343 v -2.061 h 2.062 V 45.08 h -4.321 v 5.009 l 1.964,2.848 -4.027,8.152 h -7.857 l 1.67,-3.339 H 110.2 L 112.263,53.527 110,50.384 V 44.1 l 9.33,-4.714 2.455,1.768 v 11.784 h 7.268 l -1.277,2.554 h -3.241 l -2.75,5.6 v 2.357 h 11.786 l 6.482,-13.161 -3.634,-5.107 h -4.125 v -7.565 l 4.027,3.045 1.866,-2.455 v -6.483 l -4.223,-3.143 v -1.768 l 3.143,-4.125 3.536,2.652 -1.277,1.67 z"
class="prefix__cls-1"
data-name="Path 110" />
<path
id="prefix__Path_111"
d="m 63.25,20.822 a 1.2,1.2 0 0 0 -0.884,-0.295 h -2.357 v 3.045 h 2.357 a 1.85,1.85 0 0 0 0.786,-0.2 0.658,0.658 0 0 0 0.393,-0.687 V 21.607 A 0.658,0.658 0 0 0 63.25,20.821"
class="prefix__cls-1"
data-name="Path 111" />
<path
id="prefix__Path_112"
d="m 86.331,21.215 v 2.063 h -8.349 v -2.063 l -0.393,-3.437 2.063,1.375 -0.491,1.964 h 2.455 l -0.786,-1.964 1.375,-2.259 1.375,2.259 -0.786,1.964 h 2.456 l -0.491,-1.964 2.063,-1.375 z m -0.589,10.116 -2.652,-0.295 0.393,2.652 h -2.554 l 0.393,-2.652 -2.652,0.295 v -2.554 l 2.652,0.393 -0.393,-2.652 h 2.554 l -0.393,2.554 2.652,-0.295 z m 0,10.313 -2.652,-0.393 0.393,2.652 h -2.554 l 0.393,-2.652 -2.652,0.295 v -2.555 l 2.652,0.393 -0.393,-2.554 h 2.554 l -0.393,2.554 2.652,-0.295 v 2.554 z M 72.286,44.102 H 70.813 V 18.759 h 1.473 z M 65.411,22.786 a 3.531,3.531 0 0 1 -0.2,1.08 2.648,2.648 0 0 1 -0.687,0.786 3.072,3.072 0 0 1 -0.982,0.491 4.221,4.221 0 0 1 -1.179,0.2 h -2.354 v 3.536 h -1.768 v -9.727 h 4.129 a 4.93,4.93 0 0 1 1.179,0.2 3.072,3.072 0 0 1 0.982,0.491 1.527,1.527 0 0 1 0.589,0.786 2.127,2.127 0 0 1 0.2,1.179 v 0.982 z m -0.295,18.955 a 3.981,3.981 0 0 1 -0.884,0.491 c -0.295,0.1 -0.589,0.295 -0.884,0.393 a 2.868,2.868 0 0 1 -0.884,0.2 3.028,3.028 0 0 1 -0.982,0.1 5.944,5.944 0 0 1 -1.473,-0.2 2.24,2.24 0 0 1 -1.179,-0.589 3.845,3.845 0 0 1 -0.786,-0.982 3.137,3.137 0 0 1 -0.295,-1.375 v -3.734 a 3.137,3.137 0 0 1 0.295,-1.375 3.845,3.845 0 0 1 0.786,-0.982 3.319,3.319 0 0 1 1.179,-0.589 7.581,7.581 0 0 1 1.473,-0.2 4.758,4.758 0 0 1 1.768,0.295 3.7,3.7 0 0 1 1.473,0.884 l -0.687,1.277 a 7.326,7.326 0 0 0 -1.179,-0.688 3.024,3.024 0 0 0 -1.277,-0.295 2.163,2.163 0 0 0 -0.786,0.1 4.787,4.787 0 0 0 -0.687,0.295 c -0.2,0.1 -0.295,0.295 -0.491,0.491 a 1.42,1.42 0 0 0 -0.2,0.688 v 3.732 a 1.42,1.42 0 0 0 0.2,0.688 1.184,1.184 0 0 0 0.491,0.491 4.788,4.788 0 0 0 0.688,0.295 3.585,3.585 0 0 0 1.67,0 3.489,3.489 0 0 0 0.884,-0.393 V 38.602 H 61.97 v -1.28 h 3.241 v 4.42 z M 51.17,10.902 v 36.83 l 20.33,7.857 20.33,-7.857 v -36.83 z"
class="prefix__cls-1"
data-name="Path 112" />
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -5,6 +5,7 @@
<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"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/>
<title>Zgłoszenie praktyki studenckiej</title>
<base href="/">
</head>

View File

@ -17,7 +17,6 @@ export const courseDtoTransformer: Transformer<CourseDTO, Course> = {
id: subject.id,
name: subject.name,
desiredSemesters: [],
possibleProgramEntries: [], // todo
};
}
}

View File

@ -19,7 +19,9 @@ export const internshipTypeDtoTransformer: Transformer<InternshipTypeDTO, Intern
description: subject.description ? {
pl: subject.description,
en: subject.descriptionEng || ""
} : undefined
} : undefined,
requiresDeanApproval: parseInt(subject.id || "0") == 4,
requiresInsurance: parseInt(subject.id || "0") >= 4,
}
},
reverseTransform(subject: InternshipType, context?: unknown): InternshipTypeDTO {

View File

@ -13,7 +13,7 @@ import * as internship from "./internship";
import * as upload from "./upload";
export const axios = Axios.create({
baseURL: process.env.API_BASE_URL || "https://system-praktyk.stg.kadet.net/api/",
baseURL: process.env.API_BASE_URL || `https://${window.location.hostname}/api/`,
})
axios.interceptors.request.use(config => {

View File

@ -21,20 +21,21 @@ export class ValidationError extends Error {
}
}
interface ApiError {
key: string;
parameters: { [name: string]: string },
}
interface UpdateResponse {
status: SubmissionState;
errors?: string[];
errors?: ApiError[];
}
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: {} })
) || []
);
throw new ValidationError(response.errors || []);
}
return response.status;

View File

@ -3,7 +3,7 @@ import { PageDTO, pageDtoTransformer } from "./dto/page"
import { axios } from "@/api/index";
import { prepare } from "@/routing";
const STATIC_PAGE_ENDPOINT = "/staticPage/:slug"
export const STATIC_PAGE_ENDPOINT = "/staticPage/:slug"
export async function get(slug: string): Promise<Page> {
const response = await axios.get<PageDTO>(prepare(STATIC_PAGE_ENDPOINT, { slug }))

View File

@ -2,7 +2,7 @@ import { InternshipType } from "@/data";
import { axios } from "@/api/index";
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
const AVAILABLE_INTERNSHIP_TYPES = '/internshipTypes';
const AVAILABLE_INTERNSHIP_TYPES = '/internshipTypes/current';
export async function available(): Promise<InternshipType[]> {
const response = await axios.get<InternshipTypeDTO[]>(AVAILABLE_INTERNSHIP_TYPES);

View File

@ -2,6 +2,7 @@ import { axios } from "@/api/index";
import { InternshipDocument } from "@/api/dto/internship-registration";
import { prepare } from "@/routing";
import { Identifiable } from "@/data";
import store from "@/state/store";
export enum UploadType {
Ipp = "IppScan",
@ -17,6 +18,7 @@ export interface DocumentFileInfo extends Identifiable {
const CREATE_DOCUMENT_ENDPOINT = '/document';
const DOCUMENT_UPLOAD_ENDPOINT = '/document/:id/scan';
const DOCUMENT_DOWNLOAD_ENDPOINT = 'document/:id/scan/download';
export async function create(type: UploadType) {
const response = await axios.post<InternshipDocument>(CREATE_DOCUMENT_ENDPOINT, { type });
@ -35,3 +37,7 @@ export async function fileinfo(document: InternshipDocument): Promise<DocumentFi
const response = await axios.get<DocumentFileInfo>(prepare(DOCUMENT_UPLOAD_ENDPOINT, { id: document.id as string }));
return response.data;
}
export function link(document: InternshipDocument): string {
return axios.defaults.baseURL + prepare(DOCUMENT_DOWNLOAD_ENDPOINT, { id: document.id as string }) + "?auth=" + store.getState().user.token;
}

View File

@ -15,6 +15,7 @@ import { getLocale, Locale } from "@/state/reducer/settings";
import i18n from "@/i18n";
import moment from "moment-timezone";
import { Container } from "@material-ui/core";
import { useCurrentUser } from "@/hooks";
const UserMenu = (props: HTMLProps<HTMLUListElement>) => {
const student = useSelector<AppState, Student>(state => state.student as Student);
@ -62,6 +63,7 @@ const LanguageSwitcher = ({ className, ...props }: HTMLProps<HTMLUListElement>)
function App() {
const { t } = useTranslation();
const locale = useSelector<AppState, Locale>(state => getLocale(state.settings));
const user = useCurrentUser();
useEffect(() => {
i18n.changeLanguage(locale);
@ -96,13 +98,19 @@ function App() {
</header>
<main id="content">
{ <Switch>
{ routes.map(({ name, content, middlewares = [], ...route }) => <Route { ...route } key={ name }>
{ processMiddlewares([ ...middlewares, content ]) }
</Route>) }
{ routes.map(({ name, content, middlewares = [], ...route }) =>
<Route { ...route } key={ name } render={ () => {
const Next = () => processMiddlewares([ ...middlewares, content ])
return <Next />
} } />
) }
</Switch> }
</main>
<footer className="footer">
<Container>
<Container style={{ display: 'flex', alignItems: "center" }}>
<ul className="footer__menu">
{ user?.isManager && <li><Link to="/management">{ t("management") }</Link></li> }
</ul>
<div className="footer__copyright">{ t('copyright', { date: moment() }) }</div>
</Container>
</footer>

View File

@ -1,8 +1,12 @@
import React, { HTMLProps } from "react";
import { useHorizontalSpacing } from "@/styles";
export const Actions = (props: HTMLProps<HTMLDivElement>) => {
const classes = useHorizontalSpacing(2);
export type ActionsProps = {
spacing?: number;
} & HTMLProps<HTMLDivElement>;
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center" }}/>
export const Actions = ({ spacing = 2, ...props }: ActionsProps) => {
const classes = useHorizontalSpacing(spacing);
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center", ...props.style }}/>
}

View File

@ -9,21 +9,22 @@ type AsyncProps<TValue, TError = any> = {
children: (value: TValue) => JSX.Element,
loading?: () => JSX.Element,
error?: (error: TError) => JSX.Element,
keepValue?: boolean;
}
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>
{ async, children: render, loading = defaultLoading, error = defaultError, keepValue = false }: AsyncProps<TValue, TError>
) {
if (async.isLoading || (!async.error && !async.value)) {
return loading();
if (async.value && (!async.isLoading || keepValue)) {
return render(async.value as TValue);
}
if (typeof async.error !== "undefined") {
return error(async.error);
}
return render(async.value as TValue);
return loading();
}

View File

@ -0,0 +1,46 @@
import { useState } from "react";
import React from "react";
import { createPortal } from "react-dom";
import { Button, ButtonProps, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@material-ui/core";
import { useTranslation } from "react-i18next";
export type ConfirmProps = {
children: (action: () => void) => React.ReactNode,
title?: string,
content?: React.ReactNode,
onConfirm?: () => void,
onCancel?: () => void,
confirm?: (props: Pick<ButtonProps, 'onClick'>) => React.ReactNode,
}
export function Confirm({ children, title, content, confirm, onConfirm, onCancel }: ConfirmProps) {
const [ open, setOpen ] = useState<boolean>(false);
const { t } = useTranslation();
const handleCancel = () => {
setOpen(false);
onCancel?.();
}
const handleConfirm = () => {
setOpen(false);
onConfirm?.();
}
return <>
{ children(() => { setOpen(true) }) }
{ createPortal(
<Dialog open={ open } onClose={ handleCancel }>
{ title && <DialogTitle>{ title }</DialogTitle>}
<DialogContent>
<DialogContentText>{ content || t('confirmation') }</DialogContentText>
</DialogContent>
<DialogActions>
{ confirm ? confirm({ onClick: handleConfirm }) : <Button color="primary" variant="contained" autoFocus onClick={ handleConfirm }>{ t('confirm') }</Button> }
<Button onClick={ handleCancel }>{ t('cancel') }</Button>
</DialogActions>
</Dialog>,
document.getElementById("modals") as Element,
) }
</>
}

View File

@ -0,0 +1,74 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
import { Field, Form, Formik } from "formik";
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, Typography } from "@material-ui/core";
import { CKEditorField } from "@/field/ckeditor";
import { Actions } from "@/components/actions";
import { Cancel, Send } from "mdi-material-ui";
import { createPortal } from "react-dom";
import { capitalize } from "@/helpers";
export type ContactFormValues = {
content: string;
}
const initialContactFormValues: ContactFormValues = {
content: "",
}
export type ContactDialogProps = {
onSend: (values: ContactFormValues) => void;
} & DialogProps;
export function ContactForm() {
const { t } = useTranslation();
const spacing = useSpacing(2);
return <div className={ spacing.vertical } style={{ overflow: 'hidden' }}>
<Field label={ t("forms.contact.field.content") } name="content" component={ CKEditorField }/>
</div>
}
export function ContactDialog({ onSend, ...props }: ContactDialogProps) {
const spacing = useSpacing(2);
const { t } = useTranslation();
return <Dialog { ...props } maxWidth="lg">
<Formik initialValues={ initialContactFormValues } onSubmit={ onSend }>
<Form className={ spacing.vertical }>
<DialogTitle>{ capitalize(t("forms.contact.title")) }</DialogTitle>
<DialogContent>
<ContactForm />
</DialogContent>
<DialogActions>
<Actions>
<Button variant="contained" color="primary" startIcon={ <Send /> } type="submit">{ t("send") }</Button>
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
</Actions>
</DialogActions>
</Form>
</Formik>
</Dialog>
}
export type ContactActionProps = {
children: (props: { action: () => void }) => React.ReactNode
};
export function ContactAction({ children }: ContactActionProps) {
const [open, setOpen] = useState<boolean>(false);
const handleClose = () => { setOpen(false) };
const handleSubmit = (values: ContactFormValues) => {
setOpen(false);
}
return <>
{ children({ action: () => setOpen(true) }) }
{ createPortal(
<ContactDialog open={ open } onSend={ handleSubmit } onClose={ handleClose }/>,
document.getElementById("modals") as HTMLElement
) }
</>
}

View File

@ -84,7 +84,7 @@ export const FileInfo = ({ document, ...props }: FileInfoProps) => {
</Typography>
<Actions className={ classes.actions }>
<Button className={ classes.download } startIcon={ <FileDownloadOutline /> }>{ t("download") }</Button>
<Button className={ classes.download } startIcon={ <FileDownloadOutline /> } href={ api.upload.link(document) }>{ t("download") }</Button>
</Actions>
</aside>
</div> }

View File

@ -6,6 +6,8 @@ import { Company, Office } from "@/data/company";
export interface InternshipType extends Identifiable {
label: Multilingual<string>,
description?: Multilingual<string>,
requiresDeanApproval: boolean,
requiresInsurance: boolean,
}
export interface InternshipProgramEntry extends Identifiable {

View File

@ -5,3 +5,5 @@ export interface Page extends Identifiable {
content: Multilingual<string>;
slug: string;
}
export default Page;

23
src/field/ckeditor.tsx Normal file
View File

@ -0,0 +1,23 @@
import { FieldProps } from "formik";
// @ts-ignore
import { CKEditor } from '@ckeditor/ckeditor5-react';
// @ts-ignore
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
import { FormControl, FormControlLabel, FormControlProps, FormLabel, TextFieldProps } from "@material-ui/core";
import React from "react";
export type CKEditorFieldProps = FieldProps & FormControlProps & { label?: string };
export function CKEditorField({ field, form, error, label, ...props }: CKEditorFieldProps) {
const handleChange = (_: unknown, editor: any) => {
const data = editor.getData();
form.setFieldValue(field.name, data);
form.setFieldTouched(field.name);
}
return <FormControl { ...props }>
<FormLabel style={{ marginBottom: "0.5rem" }}>{ label }</FormLabel>
<CKEditor data={ field.value } editor={ ClassicEditor } onChange={ handleChange }/>
</FormControl>
}

View File

@ -112,7 +112,7 @@ const InternshipProgramForm = () => {
if (ev.target.checked) {
setSelectedProgramEntries([ ...selectedProgramEntries, entry ]);
} else {
setSelectedProgramEntries(selectedProgramEntries.filter(cur => cur != entry));
setSelectedProgramEntries(selectedProgramEntries.filter(cur => cur.id != entry.id));
}
}
@ -133,6 +133,7 @@ const InternshipProgramForm = () => {
onBlur={ handleBlur }
/>
</Grid>
{ values.kind?.requiresDeanApproval && <Grid item xs={ 12 }><Alert severity="warning">{ t("internship.kind-requires-dean-approval") }</Alert></Grid> }
{/*<Grid item md={ 8 }>*/}
{/* {*/}
{/* values.kind === InternshipType.Other &&*/}
@ -159,6 +160,7 @@ const InternshipProgramForm = () => {
const InternshipDurationForm = () => {
const { t } = useTranslation();
const edition = useCurrentEdition();
const {
values: { startDate, endDate, workingHours },
errors,
@ -174,6 +176,8 @@ const InternshipDurationForm = () => {
const hours = useMemo(() => overrideHours !== null ? overrideHours : computedHours || null, [overrideHours, computedHours]);
const weeks = useMemo(() => hours !== null ? Math.floor(hours / workingHours) : null, [ hours ]);
const requiresDeanApproval = useMemo(() => edition?.startDate?.isAfter(startDate) || edition?.endDate?.isBefore(endDate), [ startDate, endDate ])
useUpdateEffect(() => {
setFieldTouched("hours", true);
setFieldValue("hours", hours, true);
@ -200,6 +204,9 @@ const InternshipDurationForm = () => {
minDate={ startDate }
/>
</Grid>
{ requiresDeanApproval && <Grid item xs={ 12 }>
<Alert severity="warning">{ t("internship.duration-requires-dean-approval") }</Alert>
</Grid> }
<Grid item md={ 4 }>
<Field component={ TextFieldFormik }
name="workingHours"
@ -387,7 +394,7 @@ export const InternshipForm: React.FunctionComponent = () => {
{ 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>) }
{ errors.map(message => <li key={ message.key }>{ t(`validation.api.${message.key}`, message.parameters) }</li>) }
</ul>
</Alert> }
<Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography>

View File

@ -32,9 +32,10 @@ export const PlanForm = () => {
if (!destination) {
destination = await api.upload.create(UploadType.Ipp);
dispatch({ type: InternshipPlanActions.Send, document: destination });
}
dispatch({ type: InternshipPlanActions.Send, document: destination });
await api.upload.upload(destination, file);
history.push("/");

View File

@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useFormikContext } from "formik";
import { InternshipFormValues } from "@/forms/internship";
import { useCurrentEdition } from "@/hooks";
import { ContactAction } from "@/components/contact";
export const StudentForm = () => {
const { t } = useTranslation();
@ -36,8 +37,10 @@ export const StudentForm = () => {
<TextField label={ t("forms.internship.fields.semester") } value={ student.semester || "" } disabled fullWidth/>
</Grid>
<Grid item xs={12}>
<Alert severity="warning" action={ <Button color="inherit" size="small">skontaktuj się z opiekunem</Button> }>
Powyższe dane nie poprawne?
<Alert severity="warning" action={ <ContactAction>{
({ action }) => <Button color="inherit" size="small" onClick={ action }>{ t("contact") }</Button>
}</ContactAction> }>
{ t("incorrect-data-question") }
</Alert>
</Grid>
</Grid>

View File

@ -2,6 +2,7 @@ export type Nullable<T> = { [P in keyof T]: T[P] | null }
export type Subset<T> = { [K in keyof T]?: Subset<T[K]> }
export type Dictionary<T> = { [key: string]: T };
export type OneOrMany<T> = T | T[];
export type Index = string | symbol | number;
@ -26,3 +27,23 @@ export function throttle<TArgs extends any[]>(decorated: (...args: TArgs) => voi
}, time);
}
}
export function encapsulate<T>(value: OneOrMany<T>): T[] {
if (value instanceof Array) {
return value;
}
return [ value ];
}
export function one<T>(value: OneOrMany<T>): T {
if (value instanceof Array) {
return value[0];
}
return value;
}
export function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}

View File

@ -3,6 +3,7 @@ import { AppState } from "@/state/reducer";
import { Edition, getEditionDeadlines } from "@/data/edition";
import { editionSerializationTransformer } from "@/serialization";
import { Student } from "@/data";
import { UserState } from "@/state/reducer/user";
export const useCurrentStudent = () => useSelector<AppState, Student | null>(
state => state.student
@ -16,3 +17,7 @@ export const useDeadlines = () => {
const edition = useCurrentEdition() as Edition;
return getEditionDeadlines(edition);
}
export const useCurrentUser = () => useSelector<AppState, UserState>(
state => state.user
)

View File

@ -19,7 +19,6 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
useEffect(() => {
setLoading(true);
setError(undefined);
setValue(undefined);
const myMagicNumber = semaphore.value + 1;
semaphore.value = myMagicNumber;
@ -54,9 +53,9 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
};
}
export function useAsyncState<T, TError = any>(initial: Promise<T> | undefined): AsyncState<T, TError> {
export function useAsyncState<T, TError = any>(initial?: Promise<T> | undefined): AsyncState<T, TError> {
const [promise, setPromise] = useState<Promise<T> | undefined>(initial);
const asyncState = useAsync(promise);
const asyncState = useAsync<T, TError>(promise);
return [ asyncState, setPromise ];
}

View File

@ -10,9 +10,11 @@ import { convertToRoman } from "@/utils/numbers";
const resources = {
en: {
translation: require('../translations/en.yaml'),
management: require('../translations/management.en.yaml'),
},
pl: {
translation: require('../translations/pl.yaml'),
management: require('../translations/management.pl.yaml'),
}
}

View File

@ -0,0 +1,17 @@
import { axios } from "@/api";
import { EditionDTO, editionDtoTransformer } from "@/api/dto/edition";
import { Edition } from "@/data/edition";
import { prepare } from "@/routing";
const MANAGEMENT_EDITION_INDEX_ENDPOINT = '/management/editions';
const MANAGEMENT_EDITION_ENDPOINT = '/management/editions/:edition';
export async function all(): Promise<Edition[]> {
const response = await axios.get<EditionDTO[]>(MANAGEMENT_EDITION_INDEX_ENDPOINT);
return response.data.map(dto => editionDtoTransformer.transform(dto));
}
export async function details(edition: string): Promise<Edition> {
const response = await axios.get<EditionDTO>(prepare(MANAGEMENT_EDITION_ENDPOINT, { edition }));
return editionDtoTransformer.transform(response.data);
}

View File

@ -0,0 +1,11 @@
import * as edition from "./edition"
import * as page from "./page"
import * as type from "./type"
export const api = {
edition,
page,
type
}
export default api;

View File

@ -0,0 +1,30 @@
import { Page } from "@/data/page";
import pageDtoTransformer, { PageDTO } from "@/api/dto/page";
import { axios } from "@/api";
import { STATIC_PAGE_ENDPOINT } from "@/api/page";
import { prepare } from "@/routing";
import { encapsulate, OneOrMany } from "@/helpers";
const STATIC_PAGE_INDEX_ENDPOINT = "/staticPage";
export { get, STATIC_PAGE_ENDPOINT } from "@/api/page"
export async function all(): Promise<Page[]> {
const response = await axios.get<PageDTO[]>(STATIC_PAGE_INDEX_ENDPOINT);
return response.data.map(dto => pageDtoTransformer.transform(dto));
}
export async function remove(page: OneOrMany<Pick<Page, "slug">>): Promise<void> {
const pages = encapsulate(page);
await Promise.all(pages.map(page => axios.delete(prepare(STATIC_PAGE_ENDPOINT, { slug: page.slug }))));
}
export async function save(page: Page): Promise<Page> {
const response = await axios.put<PageDTO>(
STATIC_PAGE_INDEX_ENDPOINT,
pageDtoTransformer.reverseTransform(page),
);
return pageDtoTransformer.transform(response.data);
}

View File

@ -0,0 +1,28 @@
import { InternshipType } from "@/data";
import { axios } from "@/api";
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
import { encapsulate, OneOrMany } from "@/helpers";
import { prepare } from "@/routing";
const INTERNSHIP_TYPE_INDEX_ENDPOINT = '/internshipTypes'
const INTERNSHIP_TYPE_ENDPOINT = INTERNSHIP_TYPE_INDEX_ENDPOINT + "/:id";
export async function all(): Promise<InternshipType[]> {
const response = await axios.get<InternshipTypeDTO[]>(INTERNSHIP_TYPE_INDEX_ENDPOINT);
return response.data.map(dto => internshipTypeDtoTransformer.transform(dto))
}
export async function remove(type: OneOrMany<InternshipType>): Promise<void> {
await Promise.all(encapsulate(type).map(
type => axios.delete(prepare(INTERNSHIP_TYPE_ENDPOINT, { id: type.id as string }))
));
}
export async function save(type: InternshipType): Promise<InternshipType> {
await axios.put<InternshipType>(
INTERNSHIP_TYPE_INDEX_ENDPOINT,
internshipTypeDtoTransformer.reverseTransform(type)
);
return type;
}

View File

@ -0,0 +1,15 @@
import { Actions, ActionsProps } from "@/components";
import React from "react";
import { Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
export type BulkActionsProps = ActionsProps;
export const BulkActions = ({ children, ...props }: BulkActionsProps) => {
const { t } = useTranslation("management");
return <Actions { ...props }>
<Typography variant="subtitle2">{ t("actions.bulk") }: </Typography>
{ children }
</Actions>;
};

View File

@ -0,0 +1,56 @@
import React from "react";
import { OneOrMany } from "@/helpers";
import useTheme from "@material-ui/core/styles/useTheme";
import { Trans, useTranslation } from "react-i18next";
import { Confirm } from "@/components/confirm";
import { Button, IconButton, Tooltip } from "@material-ui/core";
import { Delete } from "mdi-material-ui";
import { createBoundComponent } from "@/management/common/helpers";
export type DeleteResourceActionProps<T> = {
onDelete: (resource: OneOrMany<T>) => void;
resource: OneOrMany<T>;
label: (resource: T) => string;
children?: (action: any) => React.ReactNode;
};
export function DeleteResourceAction<T>({ onDelete, resource, children, label }: DeleteResourceActionProps<T>) {
const theme = useTheme();
const { t } = useTranslation("management");
const confirmation = <>
{ !Array.isArray(resource)
? <Trans i18nKey="confirm.delete">
Czy na pewno chcesz usunąć <strong>{ label(resource) }</strong>?
</Trans>
: <>
{ t("confirm.bulk-delete") }
<ul>
{ resource.map(current => <li key={ label(current) }>{ label(current) }</li>) }
</ul>
</>
}
</>;
return <Confirm
onConfirm={ () => onDelete(resource) }
content={ confirmation }
confirm={ props =>
<Button variant="contained" startIcon={ <Delete /> }
style={{
backgroundColor: theme.palette.error.dark,
color: theme.palette.error.contrastText,
}}
{ ...props }>
{ t("actions.delete") }
</Button>
}
>
{ action => children ? children(action) : <Tooltip title={ t("actions.delete") as string }><IconButton onClick={ action }><Delete /></IconButton></Tooltip> }
</Confirm>;
}
export function createDeleteAction<T>(props: Pick<DeleteResourceActionProps<T>, 'label' | 'onDelete'>) {
return createBoundComponent(DeleteResourceAction, props);
}

View File

@ -0,0 +1,28 @@
import React from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
const useStyles = makeStyles((theme: Theme) => createStyles({
root: {
display: "flex",
alignItems: "center"
},
icon: {
marginRight: theme.spacing(1),
display: "flex",
alignItems: "center"
}
}))
export type LabelWithIconProps = {
icon: React.ReactNode,
children: React.ReactChildren,
}
export function LabelWithIcon({ icon, children }: LabelWithIconProps) {
const classes = useStyles();
return <div className={ classes.root }>
<div className={ classes.icon }>{ icon }</div>
{ children }
</div>
}

View File

@ -0,0 +1,11 @@
import React from "react";
import { AsyncResult } from "@/hooks";
import { CircularProgress } from "@material-ui/core";
export type MaterialTableTitleProps = { result: AsyncResult<any>, label: React.ReactNode } & React.HTMLProps<HTMLDivElement>;
export const MaterialTableTitle = ({ label, result, style, ...props }: MaterialTableTitleProps) =>
<div style={ { display: "flex", alignItems: "center", ...style } } { ...props }>
{ label }
{ result.isLoading && <CircularProgress size="1.5rem" style={ { marginLeft: "1rem" } }/> }
</div>

View File

@ -0,0 +1,15 @@
import { Multilingual } from "@/data";
import React from "react";
import { Chip } from "@material-ui/core";
export type MultilingualCellProps = { value: Multilingual<React.ReactNode> }
export const MultilingualCell = ({ value }: MultilingualCellProps) => {
return <>
{ Object.keys(value).map(language => <div>
<Chip size="small" label={ language.toUpperCase() } style={ { marginRight: "0.5rem" } }/>
{ value[language as keyof Multilingual<any>] }
</div>) }
</>
}

View File

@ -0,0 +1,35 @@
import React from "react";
import { Column } from "material-table";
import { Actions } from "@/components";
import { Trans } from "react-i18next";
import { Multilingual } from "@/data";
export function actionsColumn<T extends Object>(render: (value: T) => React.ReactNode): Column<T> {
return {
title: <Trans i18nKey="management:actions.label" />,
render: value => <Actions style={{ margin: "-1rem" }} spacing={ 0 }>{ render(value) }</Actions>,
sorting: false,
width: 0,
resizable: false,
removable: false,
searchable: false,
}
}
export function createBoundComponent<T, TBoundProps extends keyof T>(Component: React.ComponentType<T>, bound: Pick<T, TBoundProps>) {
return (props: Omit<T, TBoundProps>) => <Component { ...bound as any } { ...props } />;
}
export type Comparator<T> = (a: T, b: T) => number;
export type MultilingualComparator<T> = Comparator<Multilingual<T>>;
export function createMultilingualComparator<T>(comparator: Comparator<T>): MultilingualComparator<T> {
return (a, b) => comparator(a.pl, b.pl);
}
export const multilingualStringComparator = createMultilingualComparator<string>((a, b) => a && b ? a.localeCompare(b) : 0)
export const multilingualNumberComparator = createMultilingualComparator<number>((a, b) => a - b)
export function fieldComparator<T, K extends keyof T>(field: K, comparator: Comparator<T[K]>): Comparator<T> {
return (a, b) => comparator(a[field], b[field])
}

View File

@ -0,0 +1,81 @@
import React, { useCallback, useEffect } from "react";
import { Page } from "@/pages/base";
import { useTranslation } from "react-i18next";
import { useAsync, useAsyncState } 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 { Edition } from "@/data/edition";
import { Pencil } from "mdi-material-ui";
import { Management } from "../main";
import { createPortal } from "react-dom";
import { EditStaticPageDialog } from "@/management/page/create";
export type EditionDetailsProps = {
edition: string;
}
export function EditionDetails({ edition, ...props }: EditionDetailsProps) {
const result = useAsync(useCallback(() => api.edition.details(edition), [ edition ]));
return <Async async={ result }>{ edition => <pre>{ JSON.stringify(edition, null, 2) }</pre> }</Async>
}
export function EditionsManagement() {
const { t } = useTranslation("management");
const editions = useAsync(useCallback(api.edition.all, []));
const columns: Column<Edition>[] = [
{
title: t("edition.field.id"),
field: "id",
cellStyle: { whiteSpace: "nowrap" }
},
{
title: t("edition.field.start"),
render: edition => edition.startDate.format("DD.MM.yyyy"),
customSort: (a, b) => b.startDate.unix() - a.startDate.unix(),
},
{
title: t("edition.field.end"),
render: edition => edition.endDate.format("DD.MM.yyyy"),
customSort: (a, b) => b.endDate.unix() - a.endDate.unix(),
},
{
title: t("edition.field.course"),
customSort: (a, b) => a.course.name.localeCompare(b.course.name),
render: edition => edition.course.name,
},
]
const actions: Action<Edition>[] = [
{
icon: () => <Pencil />,
onClick: () => {},
}
]
return <Page>
<Page.Header maxWidth="lg">
<Management.Breadcrumbs>
<Typography color="textPrimary">{ t("edition.index.title") }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ t("edition.index.title") }</Page.Title>
</Page.Header>
<Container maxWidth="lg">
<Async async={ editions }>
{ editions =>
<MaterialTable
columns={ columns }
data={ editions }
actions={ actions }
detailPanel={ edition => <EditionDetails edition={ edition.id as string } /> }
title={ t("edition.index.title") }
options={{ search: false, actionsColumnIndex: -1 }}
/>
}
</Async>
</Container>
</Page>;
}

54
src/management/main.tsx Normal file
View File

@ -0,0 +1,54 @@
import { BreadcrumbsProps, Container, Link, List, ListItem, ListItemIcon, ListItemText, Paper } from "@material-ui/core";
import { Page } from "@/pages/base";
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";
export const Management = {
Breadcrumbs: ({ children, ...props }: BreadcrumbsProps) => {
const { t } = useTranslation();
return <Page.Breadcrumbs { ...props }>
<Link component={ RouterLink } to={ route("management:index") }>{ t("management:title") }</Link>
{ children }
</Page.Breadcrumbs>;
}
}
type ManagementLinkProps = React.PropsWithChildren<{
icon: JSX.Element,
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();
return <Page>
<Page.Header>
<Page.Title>{ t("management:title") }</Page.Title>
</Page.Header>
<Container>
<Paper elevation={ 2 }>
<List>
<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={ <FileDocumentMultipleOutline /> } route={ route("management:static_pages") }>
{ t("management:page.index.title") }
</ManagementLink>
</List>
</Paper>
</Container>
</Page>
}

View File

@ -0,0 +1,14 @@
import { isLoggedInMiddleware } from "@/middleware";
import { useCurrentUser } from "@/hooks";
import React from "react";
import { Middleware } from "@/routing";
export const isManagerMiddleware: Middleware<any, any> = Next => isLoggedInMiddleware(() => {
const user = useCurrentUser();
if (user.isManager) {
return <Next />;
}
return <div />;
})

View File

@ -0,0 +1,46 @@
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
import React from "react";
import { Form, Formik } from "formik";
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
import { Actions } from "@/components";
import { Save } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { Cancel } from "mdi-material-ui";
import { useSpacing } from "@/styles";
import { default as StaticPage } from "@/data/page";
export type EditStaticPageDialogProps = {
onSave?: (page: StaticPage) => void;
page?: StaticPage;
} & DialogProps;
export function EditStaticPageDialog({ onSave, page, ...props }: EditStaticPageDialogProps) {
const { t } = useTranslation("management");
const spacing = useSpacing(3);
const handleSubmit = (values: StaticPageFormValues) => {
onSave?.(staticPageFormValuesTransformer.reverseTransform(values));
};
const initialValues = page
? staticPageFormValuesTransformer.transform(page)
: initialStaticPageFormValues;
return <Dialog { ...props } maxWidth="lg">
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
<Form className={ spacing.vertical }>
<DialogTitle>{ t(page ? "page.edit.title" : "page.create.title") }</DialogTitle>
<DialogContent>
<StaticPageForm />
</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,40 @@
import { default as StaticPage } from "@/data/page";
import { identityTransformer, Transformer } from "@/serialization";
import { Field, Form, FormikFormProps } from "formik";
import React from "react";
import { TextField as TextFieldFormik } from "formik-material-ui";
import { Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
import { CKEditorField } from "@/field/ckeditor";
export type StaticPageFormValues = StaticPage;
export const initialStaticPageFormValues: StaticPageFormValues = {
slug: "",
title: {
en: "",
pl: "",
},
content: {
en: "",
pl: "",
}
}
export const staticPageFormValuesTransformer: Transformer<StaticPage, StaticPageFormValues> = identityTransformer;
export function StaticPageForm() {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
return <div className={ spacing.vertical }>
<Field label={ t("page.field.slug") } name="slug" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("page.field.title") }</Typography>
<Field label={ t("translation:language.pl") } name="title.pl" fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name="title.en" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("page.field.content") }</Typography>
<Field label={ t("translation:language.pl") } name="content.pl" fullWidth component={ CKEditorField }/>
<Field label={ t("translation:language.en") } name="content.en" fullWidth component={ CKEditorField }/>
</div>
}

View File

@ -0,0 +1,157 @@
import { Page } from "@/pages/base";
import { Management } from "@/management/main";
import { Box, Button, CircularProgress, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useAsyncState } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import MaterialTable, { Action, Column } from "material-table";
import { default as StaticPage } from "@/data/page";
import { Delete, FileFind, Pencil, Refresh } from "mdi-material-ui";
import { encapsulate, one, OneOrMany } from "@/helpers";
import { Actions } from "@/components";
import { useSpacing } from "@/styles";
import { useHistory } from "react-router-dom";
import { Add, Edit } from "@material-ui/icons";
import { createPortal } from "react-dom";
import { EditStaticPageDialog } from "@/management/page/edit";
import { Confirm } from "@/components/confirm";
import useTheme from "@material-ui/core/styles/useTheme";
import { BulkActions } from "@/management/common/BulkActions";
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
import { MultilingualCell } from "@/management/common/MultilangualCell";
const label = (page: StaticPage) => page.title.pl;
export const StaticPageManagement = () => {
const { t } = useTranslation("management");
const [ result, setPagesPromise ] = useAsyncState<StaticPage[]>();
const [ selected, setSelected ] = useState<StaticPage[]>([]);
const spacing = useSpacing(2);
const updatePageList = () => {
setPagesPromise(api.page.all());
}
useEffect(updatePageList, []);
const EditStaticPageAction = ({ page }: { page: StaticPage }) => {
const [ open, setOpen ] = useState<boolean>(false);
const handlePageCreation = async (page: StaticPage) => {
await api.page.save(page);
setOpen(false);
updatePageList();
}
return <>
<Tooltip title={ t("actions.edit") as any }>
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
</Tooltip>
{ open && createPortal(
<EditStaticPageDialog open={ open } onSave={ handlePageCreation } page={ page } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const CreateStaticPageAction = () => {
const [ open, setOpen ] = useState<boolean>(false);
const handlePageCreation = async (page: StaticPage) => {
await api.page.save(page);
setOpen(false);
updatePageList();
}
return <>
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
{ createPortal(
<EditStaticPageDialog open={ open } onSave={ handlePageCreation } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const handlePageDeletion = async (page: OneOrMany<StaticPage>) => {
await api.page.remove(page);
updatePageList();
}
const DeleteStaticPageAction = createDeleteAction<StaticPage>({ label, onDelete: handlePageDeletion })
const PreviewStaticPageAction = ({ page }: { page: StaticPage }) => {
const history = useHistory();
const handlePagePreview = async () => history.push(`/${page.slug}`);
return <Tooltip title={ t("actions.preview") as string }>
<IconButton onClick={ handlePagePreview }><FileFind /></IconButton>
</Tooltip>;
}
const columns: Column<StaticPage>[] = [
{
render: page => <MultilingualCell value={ page.title }/>,
title: t("page.field.title"),
customSort: fieldComparator("title", multilingualStringComparator),
},
{
field: "slug",
title: t("page.field.slug"),
},
actionsColumn(page => <>
<EditStaticPageAction page={ page } />
<DeleteStaticPageAction resource={ page } />
<PreviewStaticPageAction page={ page } />
</>)
];
const PagePreview = ({ page }: { page: StaticPage }) =>
<Box className={ spacing.vertical } p={ 3 }>
<div>
<Typography variant="subtitle2">Polski</Typography>
<Typography variant="h2">{ page.title.pl }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.pl }} />
</div>
<div>
<Typography variant="subtitle2">English</Typography>
<Typography variant="h2">{ page.title.en }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.en }} />
</div>
</Box>
return <Page>
<Page.Header maxWidth="lg">
<Management.Breadcrumbs>
<Typography color="textPrimary">{ t("page.index.title") }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ t("page.index.title") }</Page.Title>
</Page.Header>
<Container maxWidth="lg" className={ spacing.vertical }>
<Actions>
<CreateStaticPageAction />
<Button onClick={ updatePageList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
</Actions>
{ selected.length > 0 && <BulkActions>
<DeleteStaticPageAction resource={ selected }>
{ action => <Button startIcon={ <Delete /> } onClick={ action }>{ t("actions.delete") }</Button> }
</DeleteStaticPageAction>
</BulkActions> }
<Async async={ result } keepValue>{
pages => <MaterialTable
title={ <MaterialTableTitle result={ result } label={ t("page.index.title") }/> }
columns={ columns }
data={ pages }
detailPanel={ page => <PagePreview page={ page } /> }
onSelectionChange={ pages => setSelected(pages) }
options={{ selection: true }}
/>
}</Async>
</Container>
</Page>
}
export default StaticPageManagement;

View File

@ -0,0 +1,22 @@
import { Route } from "@/routing";
import { isManagerMiddleware } from "@/management/middleware";
import { EditionsManagement } from "@/management/edition/list";
import React from "react";
import { ManagementIndex } from "@/management/main";
import StaticPageManagement from "@/management/page/list";
import { InternshipTypeManagement } from "@/management/type/list";
export const managementRoutes: Route[] = ([
{ name: "index", path: "/", content: ManagementIndex, exact: true },
{ name: "editions", path: "/editions", content: EditionsManagement },
{ name: "types", path: "/types", content: InternshipTypeManagement },
{ name: "static_pages", path: "/static-pages", content: StaticPageManagement }
] as Route[]).map(
({ name, path, middlewares = [], ...route }): Route => ({
name: `management:${ name }`,
path: `/management${ path }`,
middlewares: [ isManagerMiddleware, ...middlewares ],
...route
})
);

View File

@ -0,0 +1,47 @@
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
import React from "react";
import { Form, Formik } from "formik";
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
import { Actions } from "@/components";
import { Save } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { Cancel } from "mdi-material-ui";
import { useSpacing } from "@/styles";
import { initialInternshipTypeFormValues, InternshipTypeForm, InternshipTypeFormValues, internshipTypeFormValuesTransformer } from "@/management/type/form";
import { InternshipType } from "@/data";
export type EditInternshipTypeDialogProps = {
onSave?: (page: InternshipType) => void;
value?: InternshipType;
} & DialogProps;
export function EditInternshipTypeDialog({ onSave, value, ...props }: EditInternshipTypeDialogProps) {
const { t } = useTranslation("management");
const spacing = useSpacing(3);
const handleSubmit = (values: InternshipTypeFormValues) => {
onSave?.(internshipTypeFormValuesTransformer.reverseTransform(values));
};
const initialValues = value
? internshipTypeFormValuesTransformer.transform(value)
: initialInternshipTypeFormValues;
return <Dialog { ...props } maxWidth="lg">
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
<Form className={ spacing.vertical }>
<DialogTitle>{ t(value ? "type.edit.title" : "type.create.title") }</DialogTitle>
<DialogContent>
<InternshipTypeForm />
</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,55 @@
import React from "react";
import { InternshipType } from "@/data";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
import { Field } from "formik";
import { TextField as TextFieldFormik, Checkbox as CheckboxFormik } from "formik-material-ui";
import { FormControlLabel, FormGroup, Typography } from "@material-ui/core";
import { CKEditorField } from "@/field/ckeditor";
import { AccountCheck, ShieldCheck } from "mdi-material-ui";
import { identityTransformer, Transformer } from "@/serialization";
import { LabelWithIcon } from "@/management/common/LabelWithIcon";
export type InternshipTypeFormValues = Omit<InternshipType, 'id'>;
export const initialInternshipTypeFormValues: InternshipTypeFormValues = {
label: {
pl: "",
en: "",
},
description: {
pl: "",
en: "",
},
requiresInsurance: false,
requiresDeanApproval: false,
}
export const internshipTypeFormValuesTransformer: Transformer<InternshipType, InternshipTypeFormValues> = identityTransformer;
export function InternshipTypeForm() {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
return <div className={ spacing.vertical }>
<Typography variant="subtitle2">{ t("type.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("type.field.description") }</Typography>
<Field label={ t("translation:language.pl") } name="description.pl" fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name="description.en" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("type.field.flags") }</Typography>
<FormGroup>
<FormControlLabel
control={ <Field name="requiresDeanApproval" component={ CheckboxFormik }/> }
label={ <LabelWithIcon icon={ <AccountCheck /> }>{ t("type.flag.dean-approval") }</LabelWithIcon> }
/>
<FormControlLabel
control={ <Field name="requiresInsurance" component={ CheckboxFormik }/> }
label={ <LabelWithIcon icon={ <ShieldCheck /> }>{ t("type.flag.insurance") }</LabelWithIcon> }
/>
</FormGroup>
</div>
}

View File

@ -0,0 +1,149 @@
import { Page } from "@/pages/base";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncState } from "@/hooks";
import { InternshipType } from "@/data";
import api from "@/management/api";
import { Management } from "@/management/main";
import { Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
import { Async } from "@/components/async";
import MaterialTable, { Column } from "material-table";
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
import { AccountCheck, Delete, Refresh, ShieldCheck } from "mdi-material-ui";
import { OneOrMany } from "@/helpers";
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
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";
const label = (type: InternshipType) => type?.label?.pl;
export const InternshipTypeManagement = () => {
const { t } = useTranslation("management");
const [result, setTypesPromise] = useAsyncState<InternshipType[]>();
const [selected, setSelected] = useState<InternshipType[]>([]);
const spacing = useSpacing(2);
const updateTypeList = () => {
setTypesPromise(api.type.all());
}
const handleTypeDelete = async (type: OneOrMany<InternshipType>) => {
await api.type.remove(type);
updateTypeList();
}
useEffect(updateTypeList, []);
const DeleteTypeAction = createDeleteAction({ label, onDelete: handleTypeDelete });
const CreateTypeAction = () => {
const [ open, setOpen ] = useState<boolean>(false);
const handleTypeCreation = async (value: InternshipType) => {
await api.type.save(value);
setOpen(false);
updateTypeList();
}
return <>
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
{ open && createPortal(
<EditInternshipTypeDialog open={ open } onSave={ handleTypeCreation } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const EditTypeAction = ({ resource }: { resource: InternshipType }) => {
const [ open, setOpen ] = useState<boolean>(false);
const handleTypeCreation = async (value: InternshipType) => {
await api.type.save(value);
setOpen(false);
updateTypeList();
}
return <>
<Tooltip title={ t("actions.edit") as any }>
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
</Tooltip>
{ open && createPortal(
<EditInternshipTypeDialog open={ open } onSave={ handleTypeCreation } value={ resource } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const columns: Column<InternshipType>[] = [
{
field: "id",
title: "ID",
width: 0,
defaultSort: "asc",
filtering: false,
},
{
title: t("type.field.label"),
render: type => <MultilingualCell value={ type.label }/>,
customSort: fieldComparator("label", multilingualStringComparator),
},
{
title: t("type.field.description"),
render: type => type.description && <MultilingualCell value={ type.description }/>,
sorting: false,
},
{
title: t("type.field.flags"),
render: type => <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>,
width: 0,
filtering: true,
sorting: false,
},
actionsColumn(type => <>
<DeleteTypeAction resource={ type }/>
<EditTypeAction resource={ type }/>
</>)
];
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>
<CreateTypeAction />
<Button onClick={ updateTypeList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
</Actions>
{ selected.length > 0 && <BulkActions>
<DeleteTypeAction resource={ selected }>
{ action => <Button startIcon={ <Delete /> } onClick={ action }>{ t("actions.delete") }</Button> }
</DeleteTypeAction>
</BulkActions> }
<Async async={ result } keepValue>{
pages => <MaterialTable
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
columns={ columns }
data={ pages }
onSelectionChange={ pages => setSelected(pages) }
options={ { selection: true, pageSize: 10 } }
/>
}</Async>
</Container>
</Page>
}

View File

@ -1,8 +1,8 @@
import { Middleware, route } from "@/routing";
import { useSelector } from "react-redux";
import { AppState, isReady } from "@/state/reducer";
import { Redirect } from "react-router-dom";
import React from "react";
import { Redirect, useRouteMatch } from "react-router-dom";
import React, { useEffect } from "react";
import { UserState } from "@/state/reducer/user";
export const isReadyMiddleware: Middleware<any, any> = Next => isLoggedInMiddleware(() => {
@ -22,5 +22,7 @@ export const isLoggedInMiddleware: Middleware<any, any> = Next => {
return <Next />;
}
window.sessionStorage.setItem('back-path', window.location.pathname);
return <Redirect to={ route("user_login") } />;
}

View File

@ -12,18 +12,19 @@ import { Alert } from "@material-ui/lab";
import { Subset } from "@/helpers";
import { useDispatch } from "@/state/actions";
import { loginToEdition } from "@/pages/edition/pick";
import { useHistory } from "react-router-dom";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useDebouncedEffect } from "@/hooks/useDebouncedEffect";
export const RegisterEditionPage = () => {
const { t } = useTranslation();
const [key, setKey] = useState<string>("");
const [{ value: edition, isLoading }, setEdition] = useAsyncState<Subset<Edition> | null>(undefined);
const classes = useVerticalSpacing(3);
const dispatch = useDispatch();
const history = useHistory();
const match = useRouteMatch<any>();
const [key, setKey] = useState<string>(match.params['edition'] || "");
const [{ value: edition, isLoading }, setEdition] = useAsyncState<Subset<Edition> | null>(undefined);
useDebouncedEffect(() => {
setEdition(api.edition.get(key));

View File

@ -4,6 +4,7 @@ import { createStyles, makeStyles } from "@material-ui/core/styles";
import { useTranslation } from "react-i18next";
import React from "react";
import { CommentQuestion } from "mdi-material-ui/index";
import { ContactAction } from "@/components/contact";
export const getColorByStatus = (status: SubmissionStatus, theme: Theme) => {
switch (status) {
@ -46,8 +47,12 @@ export const Status = ({ submission } : SubmissionStatusProps) => {
return <span className={ classes.foreground }>{ t(`submission.status.${ status }`) }</span>;
}
export const ContactAction = (props: ButtonProps) => {
export const ContactButton = (props: ButtonProps) => {
const { t } = useTranslation();
return <Button startIcon={ <CommentQuestion/> } variant="outlined" color="primary" { ...props }>{ t('contact') }</Button>
return <ContactAction>{
({ action }) => <Button startIcon={ <CommentQuestion/> } variant="outlined" color="primary" { ...props } onClick={ action}>
{ t('contact') }
</Button> }
</ContactAction>
}

View File

@ -4,7 +4,7 @@ import { InsuranceState } from "@/state/reducer/insurance";
import { Actions, Step } from "@/components";
import { useTranslation } from "react-i18next";
import React from "react";
import { ContactAction } from "@/pages/steps/common";
import { ContactButton } from "@/pages/steps/common";
import { useDeadlines } from "@/hooks";
import { StepProps } from "@material-ui/core";
@ -17,7 +17,7 @@ export const InsuranceStep = (props: StepProps) => {
return <Step { ...props } label={ t("steps.insurance.header") } until={ deadline } completed={ insurance.signed } active={ !insurance.signed }>
<p>{ t(`steps.insurance.instructions`) }</p>
<Actions>
<ContactAction />
<ContactButton />
</Actions>
</Step>
}

View File

@ -9,7 +9,7 @@ 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 { ContactButton, Status } from "@/pages/steps/common";
import { Description as DescriptionIcon } from "@material-ui/icons";
import { useDeadlines } from "@/hooks";
import { InternshipDocument } from "@/api/dto/internship-registration";
@ -56,9 +56,9 @@ const PlanActions = () => {
</Actions>
case "declined":
return <Actions>
<FormAction>{ t('fix-errors') }</FormAction>
<FormAction>{ t('send-again') }</FormAction>
<TemplateAction />
<ContactAction/>
<ContactButton/>
</Actions>
case "draft":
return <Actions>

View File

@ -10,7 +10,7 @@ import { Actions, Step } from "@/components";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { ClipboardEditOutline, FileFind } from "mdi-material-ui/index";
import { ContactAction, Status } from "@/pages/steps/common";
import { ContactButton, Status } from "@/pages/steps/common";
import { useDeadlines } from "@/hooks";
const ProposalActions = () => {
@ -43,7 +43,7 @@ const ProposalActions = () => {
case "declined":
return <Actions>
<FormAction>{ t('fix-errors') }</FormAction>
<ContactAction />
<ContactButton />
</Actions>
case "draft":
return <Actions>

View File

@ -1,6 +1,6 @@
import React, { Dispatch, useEffect } from "react";
import { Page } from "@/pages/base";
import { Button, CircularProgress, Container, Typography } from "@material-ui/core";
import { Button, CircularProgress, Container, SvgIcon, 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";
@ -12,13 +12,22 @@ import { UserActions } from "@/state/actions/user";
import { getAuthorizeUrl } from "@/api/user";
import { useTranslation } from "react-i18next";
import { Loading } from "@/components/loading";
import GUTLogo from "!@svgr/webpack!@/../public/img/pg-logo.svg";
const authorizeUser = (code?: string) => async (dispatch: Dispatch<Action>, getState: () => AppState): Promise<void> => {
type AuthorizeUserOptions = {
isStudent?: boolean;
isManager?: boolean;
}
const authorizeUser = (code?: string, { isStudent = false, isManager = false }: AuthorizeUserOptions = { isStudent: true }) => async (dispatch: Dispatch<Action>, getState: () => AppState): Promise<void> => {
const token = await api.user.login(code);
dispatch({
type: UserActions.Login,
token,
isStudent,
isManager,
})
const student = await api.student.current();
@ -36,23 +45,32 @@ export const UserLoginPage = () => {
const query = new URLSearchParams(useLocation().search);
const { t } = useTranslation();
const handleSampleLogin = async () => {
await dispatch(authorizeUser());
const redirectAfterLogin = () => {
history.push(window.sessionStorage.getItem('back-path') || "/");
}
history.push(route("home"));
const handleSampleAdminLogin = async () => {
await dispatch(authorizeUser(undefined, { isManager: true }));
history.push(route("management:index"));
}
const handleSampleStudentLogin = async () => {
await dispatch(authorizeUser());
redirectAfterLogin();
}
const handlePgLogin = async () => {
history.push(route("user_login") + "/pg");
}
const classes = useVerticalSpacing(3);
const classes = useVerticalSpacing(2);
useEffect(() => {
(async function() {
if (location.pathname === `${match.path}/check/pg`) {
await dispatch(authorizeUser(query.get("code") as string));
history.push("/");
redirectAfterLogin();
}
})();
}, [ match.path ]);
@ -61,14 +79,21 @@ export const UserLoginPage = () => {
return <Page>
<Page.Header maxWidth="md">
<Page.Title>Zaloguj się</Page.Title>
<Page.Title>{ t("login") }</Page.Title>
</Page.Header>
<Container>
<Switch>
<Route path={match.path} exact>
<Container maxWidth="md" className={ classes.root }>
<Button fullWidth onClick={ handlePgLogin } variant="contained" color="primary">Zaloguj się z pomocą konta PG</Button>
<Button fullWidth onClick={ handleSampleLogin } variant="contained" color="secondary">Zaloguj jako przykładowy student</Button>
<Button fullWidth onClick={ handlePgLogin } variant="contained" color="primary" startIcon={
// @ts-ignore
<GUTLogo style={{ height: "1.25rem" }} viewBox="0 0 144 64" />
}>
{ t("login-as.gut-account") }
</Button>
<Typography variant="subtitle2">{ t("login-as.sample") }</Typography>
<Button fullWidth onClick={ handleSampleStudentLogin } variant="contained" color="secondary">{ t("login-as.sample-student")}</Button>
<Button fullWidth onClick={ handleSampleAdminLogin } variant="contained" color="secondary">{ t("login-as.sample-manager")}</Button>
</Container>
</Route>
<Route path={`${match.path}/pg`} render={ () => {

View File

@ -10,8 +10,9 @@ import PickEditionPage from "@/pages/edition/pick";
import { isLoggedInMiddleware, isReadyMiddleware } from "@/middleware";
import UserFillPage from "@/pages/user/fill";
import UserProfilePage from "@/pages/user/profile";
import { managementRoutes } from "@/management/routing";
type Route = {
export type Route = {
name?: string;
content: () => ReactComponentElement<any>,
condition?: () => boolean,
@ -36,6 +37,7 @@ export const routes: Route[] = [
// edition
{ name: "edition_register", path: "/edition/register", exact: true, content: () => <RegisterEditionPage/>, middlewares: [ isLoggedInMiddleware ] },
{ name: "edition_register_exact", path: "/edition/register/:edition", exact: true, content: () => <RegisterEditionPage/>, middlewares: [ isLoggedInMiddleware ] },
{ name: "edition_pick", path: "/edition/pick", exact: true, content: () => <PickEditionPage/>, middlewares: [ isLoggedInMiddleware ] },
// internship
@ -48,8 +50,10 @@ export const routes: Route[] = [
{ name: "user_fill", path: "/user/data", content: () => <UserFillPage/>, middlewares: [ isLoggedInMiddleware ] },
{ name: "user_profile", path: "/user/profile", content: () => <UserProfilePage/>, middlewares: [ isLoggedInMiddleware ] },
...managementRoutes,
// fallback route for 404 pages
{ name: "fallback", path: "*", content: () => <FallbackPage/> }
{ name: "fallback", path: "*", content: () => <FallbackPage/> },
]
const routeNameMap = new Map(routes.filter(({ name }) => !!name).map(({ name, path }) => [name, path instanceof Array ? path[0] : path])) as Map<string, string>

View File

@ -19,3 +19,8 @@ export type SerializationTransformer<T, TSerialized = Serializable<T>> = Transfo
export type OneWayTransformer<TFrom, TResult, TContext = never> = {
transform(subject: TFrom, context?: TContext): TResult;
}
export const identityTransformer: Transformer<any, any> = {
transform: subject => subject,
reverseTransform: subject => subject
}

View File

@ -7,6 +7,8 @@ export enum UserActions {
export interface LoginAction extends Action<UserActions.Login> {
token: string;
isStudent: boolean;
isManager: boolean;
}
export type LogoutAction = Action<UserActions.Logout>;

View File

@ -3,10 +3,14 @@ import { UserAction, UserActions } from "@/state/actions/user";
export type UserState = {
loggedIn: boolean;
token?: string;
isManager: boolean;
isStudent: boolean;
}
const initialUserState: UserState = {
loggedIn: false,
isManager: false,
isStudent: false,
}
const userReducer = (state: UserState = initialUserState, action: UserAction): UserState => {
@ -16,12 +20,12 @@ const userReducer = (state: UserState = initialUserState, action: UserAction): U
...state,
loggedIn: true,
token: action.token,
isManager: action.isManager,
isStudent: action.isManager,
}
case UserActions.Logout:
return {
loggedIn: false
};
return initialUserState;
}
return state;

View File

@ -10,5 +10,34 @@
}
.footer__copyright {
text-align: right;
margin-left: auto;
}
.footer__menu {
display: block;
padding: 0;
margin: 0;
li {
display: inline-block;
&:not(:first-child) {
margin-left: .5rem;
&::before {
content: "";
opacity: 0.6;
margin-right: .5rem;
}
}
}
a {
color: white;
text-decoration: none;
opacity: 0.8;
&:hover {
opacity: 1.0;
}
}
}

View File

View File

@ -0,0 +1,51 @@
title: Zarządzanie
create: utwórz
refresh: $t(translation:refresh)
save: zapisz
cancel: anuluj
actions:
label: Akcje
bulk: Akcje masowe
preview: Podgląd
delete: Usuń
edit: Edytuj
edition:
index:
title: "Edycje praktyk"
field:
id: Identyfikator
start: Początek
end: Koniec
course: Kierunek
type:
index:
title: "Rodzeje praktyki"
edit:
title: "Edytuj rodzaj praktyki"
create:
title: "Utwórz rodzaj praktyki"
field:
label: "Rodzaj praktyki"
description: "Opis"
flags: "Wymogi"
flag:
dean-approval: "Wymaga zgody dziekana"
insurance: "Wymaga ubezpieczenia"
page:
index:
title: Strony statyczne
field:
title: Tytuł
content: Treść
slug: Adres
create:
title: Utwórz stronę statyczną
edit:
title: Zmień stronę statyczną
confirm:
bulk-delete: Czy na pewno chcesz usunąć wszystkie wybrane strony?

View File

@ -5,6 +5,11 @@ login: zaloguj się
login-in-progress: Logowanie w toku, proszę czekać...
logout: wyloguj się
logged-in-as: zalogowany jako <1>{{ name }}</1>
login-as:
sample: "Przykładowe konta"
gut-account: "Zaloguj z pomocą konta politechnicznego"
sample-student: "Zaloguj jako przykładowy student"
sample-manager: "Zaloguj jako przykładowy pełnomocnik/administrator"
until: do {{ date, DD MMMM YYYY }}
not-before: od {{ date, DD MMMM YYYY }}
@ -21,12 +26,15 @@ contact: skontaktuj się z pełnomocnikiem
comments: Zgłoszone uwagi
send-again: wyślij ponownie
cancel: anuluj
send: wyślij
accept: zaakceptuj
accept-with-comments: zaakceptuj z uwagami
accept-without-comments: zaakceptuj bez uwag
discard: zgłoś uwagi
incorrect-data-question: "Powyższe dane nie są poprawne?"
dropzone: "Przeciągnij i upuść plik bądź kliknij, aby wybrać"
pages:
@ -58,6 +66,10 @@ forms:
sections:
personal: "Dane osobowe"
studies: "Dane kierunkowe"
contact:
title: $t(contact)
field:
content: "Treść"
internship:
fields:
start-date: Data rozpoczęcia praktyki
@ -123,6 +135,8 @@ internship:
intern:
semester: semestr {{ semester, roman }}
album: "numer albumu {{ album }}"
kind-requires-dean-approval: "Ten rodzaj praktyki/umowy wymaga akceptacji przez dziekana!"
duration-requires-dean-approval: "Taki okres trwania praktyki wymaga akceptacji przez dziekana!"
date-range: "{{ start, DD MMMM YYYY }} - {{ end, DD MMMM YYYY }}"
duration_2: "{{ duration, weeks }} tygodni"
duration_0: "{{ duration, weeks }} tydzień"
@ -190,7 +204,7 @@ steps:
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. Indywidualny Plan Praktyk musi zostać zatwierdzony
oraz podpisany przez Twojego zakłądowego opiekuna praktyki.
oraz podpisany przez Twojego zakładowego 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.
@ -211,7 +225,16 @@ steps:
instructions: >
Należy zgłosić się do pełnomocnika ds. praktyk Twojego kierunku i podpisać umowę ubezpieczenia. (TODO)
language:
pl: Polski
en: Angielski
validation:
api:
GreaterThanOrEqualValidator: Wartość pola "{{ PropertyName }}" musi być większa bądź równa {{ ComparisonValue }}.
NotEmptyValidator: Wartosć pola "{{ PropertyName }}" nie może być pusta.
NotNullValidator: Wartosć pola "{{ PropertyName }}" nie może być pusta.
PredicateValidator: Wartosć pola "{{ PropertyName }}" nie spełnia warunków walidacji.
required: "To pole jest wymagane"
email: "Wprowadź poprawny adres e-mail"
phone: "Wprowadź poprawny numer telefonu"
@ -220,3 +243,5 @@ validation:
contact-coordinator: "Skontaktuj się z koordynatorem"
download: "pobierz"
management: "zarządzanie"
refresh: "odśwież"

View File

@ -59,7 +59,7 @@ const config = {
port: parseInt(process.env.APP_PORT || "3000"),
proxy: {
"/api": {
target: "https://system-praktyk.stg.kadet.net/api/",
target: "https://system-praktyk.dev.kadet.net/api/",
changeOrigin: true,
pathRewrite: {
"^/api": ''

1272
yarn.lock

File diff suppressed because it is too large Load Diff