add QualificationsPage
This commit is contained in:
parent
a47cbab211
commit
94cd4129db
|
@ -2,6 +2,7 @@ export enum Route {
|
|||
SignInPage = '/',
|
||||
UsersPage = '/users',
|
||||
ProfessionsPage = '/professions',
|
||||
QualificationsPage = '/qualifications',
|
||||
}
|
||||
|
||||
export const PUBLIC_ROUTES = [Route.SignInPage];
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Route } from '../config/routing';
|
|||
import AppLayout from 'common/AppLayout/AppLayout';
|
||||
import UsersPage from './UsersPage/UsersPage';
|
||||
import ProfessionsPage from './ProfessionsPage/ProfessionsPage';
|
||||
import QualificationsPage from './QualificationsPage/QualificationsPage';
|
||||
|
||||
function AdminRoutes() {
|
||||
return (
|
||||
|
@ -14,6 +15,9 @@ function AdminRoutes() {
|
|||
<RRDRoute exact path={Route.ProfessionsPage}>
|
||||
<ProfessionsPage />
|
||||
</RRDRoute>
|
||||
<RRDRoute exact path={Route.QualificationsPage}>
|
||||
<QualificationsPage />
|
||||
</RRDRoute>
|
||||
</Switch>
|
||||
</AppLayout>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
import { useState } from 'react';
|
||||
import { ApolloError, useMutation } from '@apollo/client';
|
||||
import { useUpdateEffect } from 'react-use';
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import SortParam, { decodeSort } from 'libs/serialize-query-params/SortParam';
|
||||
import useQualifications from './QualificationsPage.useQualifications';
|
||||
import { validateRowsPerPage } from 'common/Table/helpers';
|
||||
import {
|
||||
MUTATION_CREATE_QUALIFICATION,
|
||||
MUTATION_UPDATE_QUALIFICATION,
|
||||
MUTATION_DELETE_QUALIFICATIONS,
|
||||
} from './mutations';
|
||||
|
||||
import { COLUMNS, DEFAULT_SORT, DialogType } from './constants';
|
||||
import {
|
||||
Maybe,
|
||||
MutationCreateQualificationArgs,
|
||||
MutationDeleteQualificationsArgs,
|
||||
MutationUpdateQualificationArgs,
|
||||
Qualification,
|
||||
QualificationInput,
|
||||
} from 'libs/graphql/types';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
IconButton,
|
||||
Paper,
|
||||
Snackbar,
|
||||
} from '@material-ui/core';
|
||||
import { Edit as EditIcon } from '@material-ui/icons';
|
||||
import Table from 'common/Table/Table';
|
||||
import TableToolbar from './components/TableToolbar/TableToolbar';
|
||||
import FormDialog from './components/FormDialog/FormDialog';
|
||||
|
||||
const QualificationsPage = () => {
|
||||
const [createQualificationMutation] = useMutation<
|
||||
any,
|
||||
MutationCreateQualificationArgs
|
||||
>(MUTATION_CREATE_QUALIFICATION, { ignoreResults: true });
|
||||
const [updateQualificationMutation] = useMutation<
|
||||
any,
|
||||
MutationUpdateQualificationArgs
|
||||
>(MUTATION_UPDATE_QUALIFICATION, { ignoreResults: true });
|
||||
const [deleteQualificationsMutation] = useMutation<
|
||||
any,
|
||||
MutationDeleteQualificationsArgs
|
||||
>(MUTATION_DELETE_QUALIFICATIONS, { ignoreResults: true });
|
||||
const [dialogType, setDialogType] = useState<DialogType>(DialogType.None);
|
||||
const [qualificationBeingEdited, setQualificationBeingEdited] = useState<
|
||||
Maybe<Qualification>
|
||||
>(null);
|
||||
const [selectedQualifications, setSelectedQualifications] = useState<
|
||||
Qualification[]
|
||||
>([]);
|
||||
const snackbar = useSnackbar();
|
||||
const [{ page, sort, search, ...rest }, setQueryParams] = useQueryParams({
|
||||
limit: NumberParam,
|
||||
page: withDefault(NumberParam, 0),
|
||||
sort: withDefault(SortParam, DEFAULT_SORT),
|
||||
search: withDefault(StringParam, ''),
|
||||
});
|
||||
const limit = validateRowsPerPage(rest.limit);
|
||||
const { qualifications, total, loading, refetch } = useQualifications(
|
||||
page,
|
||||
limit,
|
||||
sort.toString(),
|
||||
search
|
||||
);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (selectedQualifications.length > 0) {
|
||||
setSelectedQualifications([]);
|
||||
}
|
||||
}, [qualifications]);
|
||||
|
||||
const handleFormDialogSubmit = async (input: QualificationInput) => {
|
||||
try {
|
||||
if (dialogType === DialogType.Create) {
|
||||
await createQualificationMutation({ variables: { input } });
|
||||
} else {
|
||||
await updateQualificationMutation({
|
||||
variables: { input, id: qualificationBeingEdited?.id ?? -1 },
|
||||
});
|
||||
}
|
||||
await refetch();
|
||||
snackbar.enqueueSnackbar(
|
||||
dialogType === DialogType.Create
|
||||
? 'Pomyślnie utworzono kwalifikację.'
|
||||
: 'Zapisano zmiany.',
|
||||
{ variant: 'success' }
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
snackbar.enqueueSnackbar(
|
||||
e instanceof ApolloError && e.graphQLErrors.length > 0
|
||||
? e.graphQLErrors[0].message
|
||||
: e.message,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleDeleteQualifications = async () => {
|
||||
if (!window.confirm('Czy na pewno chcesz usunąć wybrane kwalifikacje?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ids = selectedQualifications.map(qualification => qualification.id);
|
||||
await deleteQualificationsMutation({ variables: { ids } });
|
||||
await refetch();
|
||||
snackbar.enqueueSnackbar(`Usuwanie przebiegło pomyślnie.`, {
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
snackbar.enqueueSnackbar(
|
||||
e instanceof ApolloError && e.graphQLErrors.length > 0
|
||||
? e.graphQLErrors[0].message
|
||||
: e.message,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (checked: boolean, items: Qualification[]) => {
|
||||
setSelectedQualifications(prevState =>
|
||||
checked
|
||||
? [...prevState, ...items]
|
||||
: prevState.filter(
|
||||
item => !items.some(otherItem => otherItem.id === item.id)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Paper>
|
||||
<TableToolbar
|
||||
search={search}
|
||||
onClickCreateQualification={() => {
|
||||
setQualificationBeingEdited(null);
|
||||
setDialogType(DialogType.Create);
|
||||
}}
|
||||
onChangeSearchValue={val => {
|
||||
setQueryParams({ page: 0, search: val });
|
||||
}}
|
||||
/>
|
||||
<Table
|
||||
selection
|
||||
columns={COLUMNS}
|
||||
data={qualifications}
|
||||
selected={selectedQualifications}
|
||||
onSelect={handleSelect}
|
||||
actions={[
|
||||
{
|
||||
icon: row => {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setQualificationBeingEdited(row);
|
||||
setDialogType(DialogType.Edit);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
);
|
||||
},
|
||||
tooltip: 'Edytuj',
|
||||
},
|
||||
]}
|
||||
loading={loading}
|
||||
orderBy={sort.orderBy}
|
||||
orderDirection={sort.orderDirection}
|
||||
onRequestSort={(orderBy, orderDirection) => {
|
||||
setQueryParams({
|
||||
page: 0,
|
||||
sort: decodeSort(orderBy + ' ' + orderDirection),
|
||||
});
|
||||
}}
|
||||
footerProps={{
|
||||
count: total,
|
||||
page,
|
||||
onChangePage: page => {
|
||||
setQueryParams({ page });
|
||||
},
|
||||
onChangeRowsPerPage: limit => {
|
||||
setQueryParams({ page: 0, limit });
|
||||
},
|
||||
rowsPerPage: limit,
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
<FormDialog
|
||||
open={
|
||||
dialogType === DialogType.Create || dialogType === DialogType.Edit
|
||||
}
|
||||
qualification={qualificationBeingEdited as QualificationInput}
|
||||
onSubmit={handleFormDialogSubmit}
|
||||
onClose={() => setDialogType(DialogType.None)}
|
||||
/>
|
||||
<Snackbar
|
||||
open={selectedQualifications.length > 0}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
message={`Wybrane kwalifikacje: ${selectedQualifications.length}`}
|
||||
action={
|
||||
<>
|
||||
<Button onClick={handleDeleteQualifications} color="secondary">
|
||||
Usuń
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => setSelectedQualifications([])}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualificationsPage;
|
|
@ -0,0 +1,36 @@
|
|||
import { useQuery } from '@apollo/client';
|
||||
import { QUERY_QUALIFICATIONS } from './queries';
|
||||
import { Query, QueryQualificationsArgs } from 'libs/graphql/types';
|
||||
|
||||
const useQualifications = (
|
||||
page: number,
|
||||
limit: number,
|
||||
sort: string,
|
||||
search: string
|
||||
) => {
|
||||
const { data, loading, refetch } = useQuery<
|
||||
Pick<Query, 'qualifications'>,
|
||||
QueryQualificationsArgs
|
||||
>(QUERY_QUALIFICATIONS, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
variables: {
|
||||
offset: page * limit,
|
||||
sort: [sort],
|
||||
limit,
|
||||
filter: {
|
||||
nameIEQ: '%' + search + '%',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
qualifications: data?.qualifications.items ?? [],
|
||||
get loading() {
|
||||
return this.qualifications.length === 0 && loading;
|
||||
},
|
||||
total: data?.qualifications.total ?? 0,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useQualifications;
|
|
@ -0,0 +1,149 @@
|
|||
import { useState } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { pick } from 'lodash';
|
||||
import { MAX_NAME_LENGTH, FORMULAS } from './constants';
|
||||
import { QualificationInput } from 'libs/graphql/types';
|
||||
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogProps,
|
||||
DialogTitle,
|
||||
MenuItem,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
|
||||
export interface FormDialogProps extends Pick<DialogProps, 'open'> {
|
||||
qualification?: QualificationInput;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: QualificationInput) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
const FormDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
qualification,
|
||||
onSubmit,
|
||||
}: FormDialogProps) => {
|
||||
const editMode = Boolean(qualification);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
errors,
|
||||
control,
|
||||
} = useForm<QualificationInput>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const classes = useStyles();
|
||||
|
||||
const _onSubmit = async (data: QualificationInput) => {
|
||||
setIsSubmitting(true);
|
||||
const filtered = editMode
|
||||
? pick(
|
||||
data,
|
||||
Object.keys(data).filter(key => data[key as keyof QualificationInput])
|
||||
)
|
||||
: data;
|
||||
const success = await onSubmit(filtered);
|
||||
setIsSubmitting(false);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<form onSubmit={handleSubmit(_onSubmit)}>
|
||||
<DialogTitle>
|
||||
{editMode ? 'Edycja kwalifikacji' : 'Tworzenie kwalifikacji'}
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nazwa kwalifikacji"
|
||||
name="name"
|
||||
defaultValue={qualification?.name}
|
||||
inputRef={register({
|
||||
required: 'Te pole jest wymagane.',
|
||||
maxLength: {
|
||||
value: MAX_NAME_LENGTH,
|
||||
message: `Maksymalna długość nazwy kwalifikacji to ${MAX_NAME_LENGTH} znaki.`,
|
||||
},
|
||||
})}
|
||||
error={!!errors.name?.message}
|
||||
helperText={errors.name?.message ?? ''}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Oznaczenie kwalifikacji"
|
||||
name="code"
|
||||
defaultValue={qualification?.code}
|
||||
inputRef={register({
|
||||
required: 'Te pole jest wymagane.',
|
||||
})}
|
||||
error={!!errors.code?.message}
|
||||
helperText={errors.code?.message ?? ''}
|
||||
/>
|
||||
<Controller
|
||||
name="formula"
|
||||
defaultValue={qualification?.formula ?? FORMULAS[0]}
|
||||
control={control}
|
||||
as={
|
||||
<TextField select fullWidth label="Formuła">
|
||||
{FORMULAS.map(formula => (
|
||||
<MenuItem key={formula} value={formula}>
|
||||
{formula}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Opis"
|
||||
name="description"
|
||||
defaultValue={qualification?.description}
|
||||
inputRef={register}
|
||||
multiline
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
variant="contained"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{editMode ? 'Zapisz' : 'Utwórz'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
dialogContent: {
|
||||
'& > *:not(:last-child)': {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default FormDialog;
|
|
@ -0,0 +1,2 @@
|
|||
export const MAX_NAME_LENGTH = 100;
|
||||
export const FORMULAS = ['Formuła 2012', 'Formuła 2017', 'Formuła 2019'];
|
|
@ -0,0 +1,60 @@
|
|||
import { useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import { Add as AddIcon } from '@material-ui/icons';
|
||||
import BaseTableToolbar from 'common/Table/TableToolbar';
|
||||
import SearchInput from 'common/Form/SearchInput';
|
||||
|
||||
export interface TableToolbarProps {
|
||||
search: string;
|
||||
onChangeSearchValue: (search: string) => void;
|
||||
onClickCreateQualification: () => void;
|
||||
}
|
||||
|
||||
const TableToolbar = ({
|
||||
search,
|
||||
onChangeSearchValue,
|
||||
onClickCreateQualification,
|
||||
}: TableToolbarProps) => {
|
||||
const classes = useStyles();
|
||||
const [_search, setSearch] = useState<string>(search);
|
||||
useDebounce(
|
||||
() => {
|
||||
onChangeSearchValue(_search);
|
||||
},
|
||||
500,
|
||||
[_search]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseTableToolbar className={classes.toolbar}>
|
||||
<SearchInput
|
||||
value={_search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
}}
|
||||
onResetValue={() => {
|
||||
setSearch('');
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Utwórz kwalifikację">
|
||||
<IconButton onClick={onClickCreateQualification}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</BaseTableToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
toolbar: {
|
||||
justifyContent: 'flex-end',
|
||||
'& > *:not(:last-child)': {
|
||||
marginRight: theme.spacing(0.5),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default TableToolbar;
|
|
@ -0,0 +1,34 @@
|
|||
import { decodeSort } from 'libs/serialize-query-params/SortParam';
|
||||
import { Column } from 'common/Table/types';
|
||||
import { Qualification } from 'libs/graphql/types';
|
||||
|
||||
export const DEFAULT_SORT = decodeSort('id DESC');
|
||||
export const COLUMNS: Column<Qualification>[] = [
|
||||
{
|
||||
field: 'id',
|
||||
sortable: true,
|
||||
label: 'ID',
|
||||
},
|
||||
{
|
||||
field: 'formula',
|
||||
sortable: true,
|
||||
label: 'Formuła',
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
sortable: true,
|
||||
label: 'Nazwa',
|
||||
valueFormatter: v => `${v.name} (${v.code})`,
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
sortable: true,
|
||||
label: 'Data utworzenia',
|
||||
type: 'datetime',
|
||||
},
|
||||
];
|
||||
export enum DialogType {
|
||||
Create = 'create',
|
||||
Edit = 'edit',
|
||||
None = '',
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { gql } from '@apollo/client';
|
||||
|
||||
export const MUTATION_CREATE_QUALIFICATION = gql`
|
||||
mutation createProfession($input: QualificationInput!) {
|
||||
createQualification(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MUTATION_UPDATE_QUALIFICATION = gql`
|
||||
mutation updateQualification($id: ID!, $input: QualificationInput!) {
|
||||
updateQualification(id: $id, input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MUTATION_DELETE_QUALIFICATIONS = gql`
|
||||
mutation deleteQualifications($ids: [ID!]!) {
|
||||
deleteQualifications(ids: $ids) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
import { gql } from '@apollo/client';
|
||||
|
||||
export const QUERY_QUALIFICATIONS = gql`
|
||||
query qualifications(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$filter: QualificationFilter
|
||||
$sort: [String!]
|
||||
) {
|
||||
qualifications(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
filter: $filter
|
||||
sort: $sort
|
||||
) {
|
||||
total
|
||||
items {
|
||||
id
|
||||
name
|
||||
code
|
||||
formula
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
Reference in New Issue