diff --git a/package.json b/package.json index 93cb25f..1379ef1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "i18next": "^19.6.0", "i18next-browser-languagedetector": "^5.0.0", "material-ui-dropzone": "^3.3.0", + "mdi-material-ui": "^6.17.0", "moment": "^2.26.0", "node-sass": "^4.14.1", "optimize-css-assets-webpack-plugin": "5.0.3", diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 0000000..9feeb59 --- /dev/null +++ b/src/components/index.tsx @@ -0,0 +1,2 @@ +export * from "./actions" +export * from "./step" diff --git a/src/components/step.tsx b/src/components/step.tsx new file mode 100644 index 0000000..0cf9cd6 --- /dev/null +++ b/src/components/step.tsx @@ -0,0 +1,46 @@ +import moment, { Moment } from "moment"; +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"; + +type StepProps = StepperStepProps & { + until?: Moment; + completedOn?: Moment; + label: string; + state?: ReactChild | null; + + /** this roughly translates into completed */ + accepted?: boolean; + + /** this roughly translates into error */ + declined?: boolean; + sent?: boolean; +} + +const now = moment(); + +export const Step = ({ until, label, completedOn, children, accepted = false, declined = false, completed = false, state = null, ...props }: StepProps) => { + const { t } = useTranslation(); + + const isLate = useMemo(() => until?.isBefore(completedOn || now), [completedOn, until]); + const left = useMemo(() => moment.duration(now.diff(until)), [until]); + + return + + { label } + { until && + { state && <> + { state } + + } + + { t('until', { date: until }) } + { isLate && - { t('late', { by: moment.duration(now.diff(until)) }) } } + { !isLate && !completed && - { t('left', { left: left }) } } + + } + + { children && { children } } + +} diff --git a/src/data/internship.ts b/src/data/internship.ts index 4961e27..bc82578 100644 --- a/src/data/internship.ts +++ b/src/data/internship.ts @@ -1,7 +1,7 @@ import { Moment } from "moment"; import { Identifiable } from "./common"; import { Student } from "@/data/student"; -import { Company } from "@/data/company"; +import { BranchOffice, Company } from "@/data/company"; export enum InternshipType { FreeInternship = "FreeInternship", @@ -61,8 +61,10 @@ export interface Internship extends Identifiable { endDate: Moment; isAccepted: boolean; lengthInWeeks: number; + hours: number; mentor: Mentor; company: Company; + office: BranchOffice; } export interface Mentor { @@ -71,3 +73,4 @@ export interface Mentor { email: string; phone: string | null; } + diff --git a/src/forms/company.tsx b/src/forms/company.tsx index c3fd10f..a728935 100644 --- a/src/forms/company.tsx +++ b/src/forms/company.tsx @@ -1,19 +1,19 @@ -import React, { HTMLProps, useEffect, useMemo, useState } from "react"; -import { BranchOffice, Company, Course, emptyAddress, emptyBranchOffice, emptyCompany, formatAddress, Mentor } from "@/data"; +import React, { HTMLProps, useMemo } from "react"; +import { BranchOffice, Company, emptyAddress, emptyBranchOffice, emptyCompany, formatAddress, Mentor } from "@/data"; import { sampleCompanies } from "@/provider/dummy"; -import { Alert, Autocomplete } from "@material-ui/lab"; -import { Button, Grid, TextField, Typography } from "@material-ui/core"; +import { Autocomplete } from "@material-ui/lab"; +import { Grid, TextField, Typography } from "@material-ui/core"; import { BoundProperty, formFieldProps } from "./helpers"; -import { InternshipFormSectionProps } from "@/forms/Internship"; -import { sampleCourse } from "@/provider/dummy/student"; +import { InternshipFormSectionProps } from "@/forms/internship"; import { emptyMentor } from "@/provider/dummy/internship"; +import { useProxyState } from "@/hooks"; export type CompanyFormProps = {} & InternshipFormSectionProps; export type BranchOfficeProps = { - company: Company, - disabled?: boolean -} + disabled?: boolean; + offices?: BranchOffice[]; +} & BoundProperty export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLProps) => (
@@ -29,11 +29,8 @@ export const OfficeItem = ({ office, ...props }: { office: BranchOffice } & HTML
) -export const BranchForm: React.FC = ({ company, disabled = false }) => { - const [office, setOffice] = useState(emptyBranchOffice) - +export const BranchForm: React.FC = ({ value: office, onChange: setOffice, offices = [], disabled = false }) => { const canEdit = useMemo(() => !office.id && !disabled, [office.id, disabled]); - const fieldProps = formFieldProps(office.address, address => setOffice({ ...office, address })) const handleCityChange = (event: any, value: BranchOffice | string | null) => { @@ -63,13 +60,11 @@ export const BranchForm: React.FC = ({ company, disabled = fa }) } - useEffect(() => void (office.id && setOffice(emptyBranchOffice)), [company]) - return (
- typeof office == "string" ? office : office.address.city } renderOption={ office => } @@ -122,14 +117,12 @@ export const MentorForm = ({ mentor, onMentorChange }: BoundProperty = ({ internship, onChange }) => { - const [company, setCompany] = useState(emptyCompany); - const [mentor, setMentor] = useState(emptyMentor); + const [company, setCompany] = useProxyState(internship.company || emptyCompany, company => onChange({ ...internship, company })); + const [mentor, setMentor] = useProxyState(internship.mentor || emptyMentor, mentor => onChange({ ...internship, mentor })); + const [office, setOffice] = useProxyState(internship.office || emptyBranchOffice, office => onChange({ ...internship, office })); const canEdit = useMemo(() => !company.id, [company.id]); - useEffect(() => onChange({ ...internship, mentor }), [ mentor ]); - useEffect(() => onChange({ ...internship, company }), [ company ]); - const fieldProps = formFieldProps(company, setCompany) const handleCompanyChange = (event: any, value: Company | string | null) => { @@ -153,7 +146,7 @@ export const CompanyForm: React.FunctionComponent = ({ interns getOptionLabel={ option => option.name } renderOption={ company => } renderInput={ props => } - onChange={ handleCompanyChange } + onChange={ handleCompanyChange } value={ company } freeSolo /> @@ -167,7 +160,7 @@ export const CompanyForm: React.FunctionComponent = ({ interns Zakładowy opiekun praktyki Oddział - + ) } diff --git a/src/forms/helpers.ts b/src/forms/helpers.ts index 96fdbde..5eabad7 100644 --- a/src/forms/helpers.ts +++ b/src/forms/helpers.ts @@ -15,7 +15,7 @@ export function formFieldProps(subject: T, update: (value: T) => void, option return

( field: P, extractor: (...args: TArgs) => T[P] = ((event: DOMEvent) => event.target.value as unknown as T[P]) as any - ) => ({ + ): any => ({ [property]: subject[field], [event]: (...args: TArgs) => update({ ...subject, diff --git a/src/forms/Internship.tsx b/src/forms/internship.tsx similarity index 71% rename from src/forms/Internship.tsx rename to src/forms/internship.tsx index 967b1b6..abd58f4 100644 --- a/src/forms/Internship.tsx +++ b/src/forms/internship.tsx @@ -1,18 +1,5 @@ -import React, { HTMLProps, useMemo, useState } from "react"; -import { - FormControl, - Grid, - Input, - InputLabel, - Typography, - FormHelperText, - TextField, - FormGroup, - FormControlLabel, - Checkbox, - FormLabel, - Button -} from "@material-ui/core"; +import React, { HTMLProps, useEffect, useMemo, useState } from "react"; +import { Button, FormControl, FormHelperText, Grid, Input, InputLabel, TextField, Typography } from "@material-ui/core"; import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers"; import { CompanyForm } from "@/forms/company"; import { StudentForm } from "@/forms/student"; @@ -24,6 +11,14 @@ import { computeWorkingHours } from "@/utils/date"; import { Autocomplete } from "@material-ui/lab"; import { formFieldProps } from "@/forms/helpers"; import { emptyInternship } from "@/provider/dummy/internship"; +import { InternshipProposalActions, useDispatch } from "@/state/actions"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import { AppState } from "@/state/reducer"; +import { useHistory } from "react-router-dom"; +import { route } from "@/routing"; +import { useProxyState } from "@/hooks"; +import { getInternshipProposal } from "@/state/reducer/proposal"; export type InternshipFormProps = {} @@ -62,25 +57,26 @@ const InternshipProgramForm = ({ internship, onChange }: InternshipFormSectionPr { internship.type === InternshipType.Other && } - - - Realizowane punkty programu praktyk (minimum 3) - { course.possibleProgramEntries.map(entry => { - return ( - } - /> - ) - }) } - - + {/**/} + {/* */} + {/* Realizowane punkty programu praktyk (minimum 3)*/} + {/* { course.possibleProgramEntries.map(entry => {*/} + {/* return (*/} + {/* }*/} + {/* />*/} + {/* )*/} + {/* }) }*/} + {/* */} + {/**/} ) } -const InternshipDurationForm = ({ internship }: InternshipFormSectionProps) => { - const [startDate, setStartDate] = useState(internship.startDate); - const [endDate, setEndDate] = useState(internship.endDate); +const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionProps) => { + const [startDate, setStartDate] = useProxyState(internship.startDate, value => onChange({ ...internship, startDate: value })); + const [endDate, setEndDate] = useProxyState(internship.endDate, value => onChange({ ...internship, endDate: value })); + const [overrideHours, setHoursOverride] = useState(null) const [workingHours, setWorkingHours] = useState(40) @@ -89,6 +85,8 @@ const InternshipDurationForm = ({ internship }: InternshipFormSectionProps) => { const hours = useMemo(() => overrideHours !== null ? overrideHours : computedHours || null, [overrideHours, computedHours]); const weeks = useMemo(() => hours !== null ? Math.floor(hours / 40) : null, [ hours ]); + useEffect(() => onChange({ ...internship, hours }), [hours]) + return ( @@ -140,7 +138,18 @@ const InternshipDurationForm = ({ internship }: InternshipFormSectionProps) => { } export const InternshipForm: React.FunctionComponent = props => { - const [internship, setInternship] = useState>({ ...emptyInternship, intern: sampleStudent }) + const initialInternshipState = useSelector>(state => getInternshipProposal(state.proposal) || { ...emptyInternship, intern: sampleStudent }); + + const [internship, setInternship] = useState>(initialInternshipState) + const { t } = useTranslation(); + + const dispatch = useDispatch(); + const history = useHistory(); + + const handleSubmit = () => { + dispatch({ type: InternshipProposalActions.Send, internship: internship as Internship }); + history.push(route("home")) + } return (

@@ -152,7 +161,7 @@ export const InternshipForm: React.FunctionComponent = prop Miejsce odbywania praktyki - +
) } diff --git a/src/helpers.ts b/src/helpers.ts index e128e6c..b24217f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,10 @@ export type Nullable = { [P in keyof T]: T[P] | null } +export type Partial = { [K in keyof T]?: T[K] } +export type Dictionary = { [key: string]: T }; + +export type Index = string | symbol | number; + export interface DOMEvent extends Event { target: TTarget; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..283d0e1 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useProxyState" diff --git a/src/hooks/useProxyState.ts b/src/hooks/useProxyState.ts new file mode 100644 index 0000000..3dd51fa --- /dev/null +++ b/src/hooks/useProxyState.ts @@ -0,0 +1,9 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +export function useProxyState(initial: T, setter: (value: T) => void): [T, Dispatch>] { + const [value, proxy] = useState(initial); + + useEffect(() => setter(value), [ value ]); + + return [value, proxy]; +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 538a1bd..c5f71a3 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,3 @@ export * from "./internship/proposal"; export * from "./errors/not-found" export * from "./main" -export { Actions } from "@/components/actions"; diff --git a/src/pages/internship/proposal.tsx b/src/pages/internship/proposal.tsx index 00e233c..a9ef954 100644 --- a/src/pages/internship/proposal.tsx +++ b/src/pages/internship/proposal.tsx @@ -2,7 +2,7 @@ import { Page } from "@/pages/base"; import { Container, Link, Typography } from "@material-ui/core"; import { Link as RouterLink } from "react-router-dom"; import { route } from "@/routing"; -import { InternshipForm } from "@/forms/Internship"; +import { InternshipForm } from "@/forms/internship"; import React from "react"; export const InternshipProposalPage = () => { diff --git a/src/pages/main.tsx b/src/pages/main.tsx index c48e059..46a1eaa 100644 --- a/src/pages/main.tsx +++ b/src/pages/main.tsx @@ -1,45 +1,69 @@ import React, { useMemo } from "react"; import { Page } from "@/pages/base"; -import { Box, Button, Container, Step as StepperStep, StepContent, StepLabel, Stepper, StepProps as StepperStepProps, Typography } from "@material-ui/core"; +import { Button, Container, Stepper, StepProps, Theme, Typography } from "@material-ui/core"; import { Link as RouterLink } from "react-router-dom"; import { route } from "@/routing"; import { useTranslation } from "react-i18next"; -import moment, { Moment } from "moment"; import { useSelector } from "react-redux"; import { AppState } from "@/state/reducer"; import { getMissingStudentData, Student } from "@/data"; import { Deadlines, Edition, getEditionDeadlines } from "@/data/edition"; import { Description as DescriptionIcon } from "@material-ui/icons" -import { Actions } from "@/components/actions"; +import { Actions, Step } from "@/components"; +import { getInternshipProposalStatus, InternshipProposalState, InternshipProposalStatus } from "@/state/reducer/proposal"; +import { createStyles, makeStyles } from "@material-ui/core/styles"; -type StepProps = StepperStepProps & { - until?: Moment; - completedOn?: Moment; - label: string; + +const getColorByStatus = (status: InternshipProposalStatus, theme: Theme) => { + switch (status) { + case "awaiting": + return theme.palette.info.dark; + case "accepted": + return theme.palette.success.dark; + case "declined": + return theme.palette.error.dark; + case "draft": + return theme.palette.grey["800"]; + default: + return "textPrimary"; + } } -const now = moment(); +const useStatusStyles = makeStyles((theme: Theme) => { + const colorByStatusGetter = ({ status }: any) => getColorByStatus(status, theme); + + return createStyles({ + foreground: { + color: colorByStatusGetter + }, + background: { + backgroundColor: colorByStatusGetter + } + }) +}) + +const ProposalStatus = () => { + const status = useSelector(state => getInternshipProposalStatus(state.proposal)) + const classes = useStatusStyles({ status }); -const Step = ({ until, label, completedOn, children, completed, ...props }: StepProps) => { const { t } = useTranslation(); - const isLate = useMemo(() => until?.isBefore(completedOn || now), [completedOn, until]); - const left = useMemo(() => moment.duration(now.diff(until)), [until]); + return { t(`proposal.status.${status}`) }; +} - return - - { label } - { until && - - { t('until', { date: until }) } - { isLate && - { t('late', { by: moment.duration(now.diff(until)) }) } } - { !isLate && !completed && - { t('left', { left: left }) } } - - } - - { children && { children } } - +const ProposalStep = (props: StepProps) => { + const { t } = useTranslation(); + + const { sent } = useSelector(state => state.proposal); + const deadlines = useSelector(state => getEditionDeadlines(state.edition as Edition)); // edition cannot be null at this point + + return }> +

{ t('steps.internship-proposal.info') }

+ + +
; } export const MainPage = () => { @@ -67,13 +91,7 @@ export const MainPage = () => { } - -

{ t('steps.internship-proposal.info') }

- - -
+

{ t('steps.plan.info') }

@@ -81,7 +99,7 @@ export const MainPage = () => { - diff --git a/src/serialization/index.ts b/src/serialization/index.ts new file mode 100644 index 0000000..e6efcb2 --- /dev/null +++ b/src/serialization/index.ts @@ -0,0 +1,3 @@ +export * from "./internship" +export * from "./moment" +export * from "./types" diff --git a/src/serialization/internship.ts b/src/serialization/internship.ts new file mode 100644 index 0000000..be27ddf --- /dev/null +++ b/src/serialization/internship.ts @@ -0,0 +1,17 @@ +import { Internship, InternshipType } from "@/data"; +import { Serializable, SerializationTransformer } from "@/serialization/types"; +import { momentSerializationTransformer } from "@/serialization/moment"; + +export const internshipSerializationTransformer: SerializationTransformer = { + transform: (internship: Internship): Serializable => ({ + ...internship, + startDate: momentSerializationTransformer.transform(internship.startDate), + endDate: momentSerializationTransformer.transform(internship.endDate), + }), + reverseTransform: (serialized: Serializable): Internship => ({ + ...serialized, + startDate: momentSerializationTransformer.reverseTransform(serialized.startDate), + endDate: momentSerializationTransformer.reverseTransform(serialized.endDate), + type: serialized.type as InternshipType, + }), +} diff --git a/src/serialization/moment.ts b/src/serialization/moment.ts new file mode 100644 index 0000000..19acae1 --- /dev/null +++ b/src/serialization/moment.ts @@ -0,0 +1,7 @@ +import { SerializationTransformer } from "@/serialization/types"; +import moment, { Moment } from "moment"; + +export const momentSerializationTransformer: SerializationTransformer = { + transform: (subject: Moment) => subject.toISOString(), + reverseTransform: (subject: string) => moment(subject), +} diff --git a/src/serialization/types.ts b/src/serialization/types.ts new file mode 100644 index 0000000..94e443d --- /dev/null +++ b/src/serialization/types.ts @@ -0,0 +1,18 @@ +import { Moment } from "moment"; + +type Simplify = string | + T extends string ? string : + T extends number ? number : + T extends boolean ? boolean : + T extends Moment ? string : + T extends Array ? Array> : + T extends (infer K)[] ? Simplify[] : + T extends Object ? Serializable : any; + +export type Serializable = { [K in keyof T]: Simplify } +export type Transformer = { + transform(subject: TFrom): TResult; + reverseTransform(subject: TResult): TFrom; +} + +export type SerializationTransformer> = Transformer diff --git a/src/state/actions/proposal.ts b/src/state/actions/proposal.ts index a7876c4..8189ec3 100644 --- a/src/state/actions/proposal.ts +++ b/src/state/actions/proposal.ts @@ -18,7 +18,7 @@ export interface ReceiveProposalApproveAction extends Action { - + comment: string; } export interface ReceiveProposalUpdateAction extends Action { diff --git a/src/state/reducer/proposal.ts b/src/state/reducer/proposal.ts index c779e70..1ccb187 100644 --- a/src/state/reducer/proposal.ts +++ b/src/state/reducer/proposal.ts @@ -1,28 +1,33 @@ import { DeanApproval } from "@/data/deanApproval"; import { InternshipProposalAction, InternshipProposalActions } from "@/state/actions"; import { Internship } from "@/data"; +import moment from "moment"; +import { Serializable } from "@/serialization/types"; +import { internshipSerializationTransformer, momentSerializationTransformer } from "@/serialization"; -export type ProposalStatus = "draft" | "awaiting" | "accepted" | "declined"; +export type InternshipProposalStatus = "draft" | "awaiting" | "accepted" | "declined"; export type InternshipProposalState = { accepted: boolean; sent: boolean; + sentOn: string | null; declined: boolean; requiredDeanApprovals: DeanApproval[]; - proposal: Internship | null; + proposal: Serializable | null; comment: string | null; } const defaultInternshipProposalState: InternshipProposalState = { accepted: false, declined: false, + sentOn: null, proposal: null, requiredDeanApprovals: [], sent: false, comment: null } -const getInternshipProposalStatus = ({ accepted, declined, sent }: InternshipProposalState): ProposalStatus => { +export const getInternshipProposalStatus = ({ accepted, declined, sent }: InternshipProposalState): InternshipProposalStatus => { switch (true) { case !sent: return "draft"; @@ -37,13 +42,38 @@ const getInternshipProposalStatus = ({ accepted, declined, sent }: InternshipPro } } +export const getInternshipProposal = ({ proposal }: InternshipProposalState): Internship | null => + proposal && internshipSerializationTransformer.reverseTransform(proposal); + const internshipProposalReducer = (state: InternshipProposalState = defaultInternshipProposalState, action: InternshipProposalAction): InternshipProposalState => { switch (action.type) { + case InternshipProposalActions.Approve: + return { + ...state, + accepted: true, + declined: false, + comment: "" + } + case InternshipProposalActions.Decline: + return { + ...state, + accepted: false, + declined: true, + comment: action.comment + } case InternshipProposalActions.Save: + return { + ...state, + proposal: internshipSerializationTransformer.transform(action.internship), + } case InternshipProposalActions.Send: return { ...state, - proposal: action.internship + proposal: internshipSerializationTransformer.transform(action.internship), + sent: true, + sentOn: momentSerializationTransformer.transform(moment()), + accepted: false, + declined: false, } default: return state; diff --git a/src/state/store.ts b/src/state/store.ts index f35ec6d..d3f3095 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -16,6 +16,8 @@ const store = createStore( devToolsEnhancer({}) ); -export const persistor = persistStore(store) +export const persistor = persistStore(store); + +(window as any)._store = store; export default store; diff --git a/translations/pl.yaml b/translations/pl.yaml index e2c6ab2..7fd75de 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -32,6 +32,13 @@ student: email: adres e-mail albumNumber: numer albumu +proposal: + status: + awaiting: "wysłano, oczekuje na weryfikacje" + accepted: "zaakceptowano" + declined: "do poprawy" + draft: "wersja robocza" + steps: personal-data: header: "Uzupełnienie informacji" diff --git a/webpack.config.js b/webpack.config.js index 6aeedda..dae0296 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -53,6 +53,7 @@ const config = { host: 'system-praktyk-front.localhost', disableHostCheck: true, historyApiFallback: true, + overlay: true, }, optimization: { usedExports: true diff --git a/yarn.lock b/yarn.lock index 9b75961..697aea8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5521,6 +5521,11 @@ md5.js@^1.3.4: inherits "^2.0.1" safe-buffer "^5.1.2" +mdi-material-ui@^6.17.0: + version "6.17.0" + resolved "https://registry.yarnpkg.com/mdi-material-ui/-/mdi-material-ui-6.17.0.tgz#da69f0b7d7c6fc2255e6007ed8b8ca858c1aede7" + integrity sha512-eOprRu31lklPIS1WGe3cM0G/8glKl1WKRvewxjDrgXH2Ryxxg7uQ+uwDUwUEONtLku0p2ZOLzgXUIy2uRy5rLg== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b"