feat: departments and department sections

This commit is contained in:
2024-12-17 12:43:33 +04:00
parent 43bba73f06
commit 31a959236c
40 changed files with 1220 additions and 11 deletions

View File

@@ -2,14 +2,13 @@ import styles from "./AdminPage.module.css";
import { Tabs } from "@mantine/core";
import PageBlock from "../../components/PageBlock/PageBlock.tsx";
import {
IconBriefcase,
IconTopologyStar3,
IconCalendarUser,
IconCoins,
IconCurrencyDollar,
IconQrcode,
IconUser,
} from "@tabler/icons-react";
import RolesAndPositionsTab from "./tabs/RolesAndPositions/RolesAndPositionsTab.tsx";
import UsersTab from "./tabs/Users/UsersTab.tsx";
import { motion } from "framer-motion";
import FinancesTab from "./tabs/Finances/FinancesTab.tsx";
@@ -18,6 +17,7 @@ import { WorkShiftsTab } from "./tabs/WorkShifts/WorkShiftsTab.tsx";
import { TransactionsTab } from "./tabs/Transactions/TransactionsTab.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store.ts";
import OrganizationalStructureTab from "./tabs/OrganizationalStructureTab/OrganizationalStructureTab.tsx";
const AdminPage = () => {
const userRole = useSelector((state: RootState) => state.auth.role);
@@ -45,8 +45,8 @@ const AdminPage = () => {
)}
<Tabs.Tab
value={"rolesAndPositions"}
leftSection={<IconBriefcase />}>
Должности
leftSection={<IconTopologyStar3 />}>
Организационная структура
</Tabs.Tab>
{isAdmin && (
<Tabs.Tab
@@ -81,7 +81,7 @@ const AdminPage = () => {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<RolesAndPositionsTab />
<OrganizationalStructureTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"finances"}>

View File

@@ -0,0 +1,48 @@
import { Tabs } from "@mantine/core";
import { IconBriefcase, IconSitemap } from "@tabler/icons-react";
import { motion } from "framer-motion";
import RolesAndPositions from "./components/RolesAndPositions.tsx";
import Departments from "./components/Departments.tsx";
const OrganizationalStructureTab = () => {
return (
<Tabs
keepMounted={false}
defaultValue={"departments"}
color={"gray.6"}>
<Tabs.List
justify={"center"}
grow>
<Tabs.Tab
value={"departments"}
leftSection={<IconSitemap />}>
Департаменты и отделы
</Tabs.Tab>
<Tabs.Tab
value={"positions"}
leftSection={<IconBriefcase />}>
Должности
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={"departments"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<Departments />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"positions"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<RolesAndPositions />
</motion.div>
</Tabs.Panel>
</Tabs>
);
};
export default OrganizationalStructureTab;

View File

@@ -0,0 +1,35 @@
import { Button, Group, rem } from "@mantine/core";
import { IconChevronsDown, IconChevronsUp, IconPlus } from "@tabler/icons-react";
import { useDepartmentContext } from "../contexts/DepartmentContext.tsx";
const DepartmentButtons = () => {
const {
departmentIds,
toggleAllDepartmentIds,
onCreateDepartmentClick,
} = useDepartmentContext();
return (
<Group gap={"md"}>
<Button
variant={"default"}
onClick={toggleAllDepartmentIds}
>
{departmentIds.length > 0 ? <IconChevronsUp /> : <IconChevronsDown />}
</Button>
<Button
variant={"default"}
onClick={() => onCreateDepartmentClick(false)}
>
<Group gap={rem(10)}>
<IconPlus />
Добавить департамент
</Group>
</Button>
</Group>
);
};
export default DepartmentButtons;

View File

@@ -0,0 +1,17 @@
import { Stack } from "@mantine/core";
import DepartmentsTree from "./DepartmentsTree.tsx";
import DepartmentButtons from "./DepartmentButtons.tsx";
import { DepartmentContextProvider } from "../contexts/DepartmentContext.tsx";
const Departments = () => {
return (
<DepartmentContextProvider>
<Stack mt={"md"}>
<DepartmentButtons />
<DepartmentsTree />
</Stack>
</DepartmentContextProvider>
);
};
export default Departments;

View File

@@ -0,0 +1,139 @@
import { Accordion, ActionIcon, Center, Flex, Text, Title, Tooltip } from "@mantine/core";
import { IconEdit, IconPlaylistAdd, IconTrash, IconUserPlus } from "@tabler/icons-react";
import { DepartmentSchema, DepartmentSectionSchema } from "../../../../../client";
import { useDepartmentContext } from "../contexts/DepartmentContext.tsx";
import UsersTable from "./UsersTable.tsx";
import { ReactNode } from "react";
const DepartmentsTree = () => {
const {
departments,
departmentIds,
setDepartmentIds,
onCreateDepartmentClick,
onUpdateDepartmentClick,
onDeleteDepartmentClick,
onAddUserClick,
} = useDepartmentContext();
const getAction = (label: string, func: () => void, icon: ReactNode) => {
return (
<Tooltip label={label} key={label}>
<ActionIcon
variant={"default"}
onClick={func}
>
{icon}
</ActionIcon>
</Tooltip>
);
};
const getActions = (
element: DepartmentSectionSchema | DepartmentSchema,
isSection: boolean,
) => {
const actions = [
isSection ? (
getAction(
"Добавить пользователя в отдел",
() => onAddUserClick(element as DepartmentSectionSchema),
<IconUserPlus />,
)
) : (
getAction(
"Добавить отдел",
() => onCreateDepartmentClick(true, { departmentId: element.id, name: "" }),
<IconPlaylistAdd />,
)
),
getAction(
"Редактировать",
() => onUpdateDepartmentClick(element, isSection),
<IconEdit />,
),
getAction(
"Удалить",
() => onDeleteDepartmentClick(element, isSection),
<IconTrash />,
),
];
return (
<Flex gap={"md"} mx={"md"} direction={"row"}>
{...actions}
</Flex>
);
};
const getUsersTable = (section: DepartmentSectionSchema) => {
if (!section.users || section.users.length === 0) {
return <Text>Сотрудники не добавлены</Text>;
}
return <UsersTable users={section.users} sectionId={section.id}/>;
};
const getDepartmentSections = (department: DepartmentSchema) => {
if (!department.sections || department.sections.length === 0) return;
const accordionIds: string[] = [];
const sortedSections: DepartmentSectionSchema[] = department.sections?.sort((a, b) => a.id - b.id) ?? [];
const items = sortedSections.map(section => {
const value = `section ${section.id}`;
accordionIds.push(value);
return (
<Accordion.Item key={section.id} value={value}>
<Center>
<Accordion.Control>
<Title order={4}>Отдел - {section.name}</Title>
</Accordion.Control>
{getActions(section, true)}
</Center>
<Accordion.Panel>
{getUsersTable(section)}
</Accordion.Panel>
</Accordion.Item>
);
});
return (
<Accordion
multiple={true}
defaultValue={accordionIds}
bd={"solid 1px gray"}
>
{items}
</Accordion>
);
};
const getDepartments = departments.map((department) => {
return (
<Accordion.Item key={department.id} value={department.id.toString()}>
<Center>
<Accordion.Control>
<Title order={4}>Департамент - {department.name}</Title>
</Accordion.Control>
{getActions(department, false)}
</Center>
<Accordion.Panel>
{getDepartmentSections(department)}
</Accordion.Panel>
</Accordion.Item>
);
});
return (
<Accordion
multiple={true}
value={departmentIds}
onChange={value => setDepartmentIds(value)}
bd={"solid 1px gray"}
>
{getDepartments}
</Accordion>
);
};
export default DepartmentsTree;

View File

@@ -1,9 +1,9 @@
import PositionsTable from "../../components/PositionsTable/PositionsTable.tsx";
import usePositionsList from "../../hooks/usePositionsList.tsx";
import { PositionSchema, PositionService } from "../../../../client";
import { notifications } from "../../../../shared/lib/notifications.ts";
import PositionsTable from "../../../components/PositionsTable/PositionsTable.tsx";
import usePositionsList from "../../../hooks/usePositionsList.tsx";
import { PositionSchema, PositionService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
const RolesAndPositionsTab = () => {
const RolesAndPositions = () => {
const { objects: positions, refetch } = usePositionsList();
const onCreate = (position: PositionSchema) => {
PositionService.createPosition({
@@ -36,4 +36,4 @@ const RolesAndPositionsTab = () => {
/>
);
};
export default RolesAndPositionsTab;
export default RolesAndPositions;

View File

@@ -0,0 +1,27 @@
import { FC } from "react";
import ObjectSelect, { ObjectSelectProps } from "../../../../../components/ObjectSelect/ObjectSelect.tsx";
import { UserSchema } from "../../../../../client";
import useAvailableUsersList from "../hooks/useAvailableUsersList.tsx";
type SectionData = {
sectionId: number;
}
type Props = SectionData & Omit<
ObjectSelectProps<UserSchema | null>,
"data" | "getValueFn" | "getLabelFn"
>;
const UserForDepartmentSelect: FC<Props> = props => {
const { objects: users } = useAvailableUsersList({ sectionId: props.sectionId });
return (
<ObjectSelect
data={users}
getLabelFn={(user: UserSchema) => `${user.firstName} ${user.secondName}`}
getValueFn={(user: UserSchema) => user.id.toString()}
{...props}
/>
);
};
export default UserForDepartmentSelect;

View File

@@ -0,0 +1,63 @@
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import useUsersTableColumns from "../hooks/useUsersTableColumns.tsx";
import { DepartmentService, UserSchema } from "../../../../../client";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useDepartmentContext } from "../contexts/DepartmentContext.tsx";
type Props = {
users: UserSchema[];
sectionId: number;
}
const UsersTable = ({ users, sectionId }: Props) => {
const columns = useUsersTableColumns();
const { fetchDepartments } = useDepartmentContext();
const onDeleteUserClick = (userId: number) => {
DepartmentService.deleteUser({
requestBody: {
sectionId,
userId,
}
})
.then(({ok, message}) => {
fetchDepartments();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
}
return (
<BaseTable
data={users}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
positionActionsColumn: "last",
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteUserClick(row.original.id)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<UserSchema>
}
/>
)
}
export default UsersTable;

View File

@@ -0,0 +1,153 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
import {
DepartmentSchema,
DepartmentSectionBaseSchema,
DepartmentSectionSchema,
DepartmentService,
} from "../../../../../client";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import useDepartmentCrud from "../hooks/useDepartmentCrud.tsx";
import useDepartmentSectionCrud from "../hooks/useDepartmentSectionCrud.tsx";
type DepartmentContextState = {
departments: DepartmentSchema[],
departmentIds: string[],
setDepartmentIds: React.Dispatch<React.SetStateAction<string[]>>,
fetchDepartments: () => void,
onCreateDepartmentClick: (isSection: boolean, element?: DepartmentSectionBaseSchema) => void,
onUpdateDepartmentClick: (element: DepartmentSchema | DepartmentSectionSchema, isSection: boolean) => void,
onDeleteDepartmentClick: (element: DepartmentSchema, isSection: boolean) => void,
onAddUserClick: (departmentSection: DepartmentSectionSchema) => void,
toggleAllDepartmentIds: () => void,
};
const DepartmentContext = createContext<DepartmentContextState | undefined>(
undefined,
);
const useDepartmentContextState = () => {
const [departments, setDepartments] = useState<DepartmentSchema[]>([]);
const [departmentIds, setDepartmentIds] = useState<string[]>([]);
const fetchDepartments = () => {
DepartmentService.getDepartments()
.then(res => {
setDepartments(res.departments);
if (departmentIds.length === 0) {
setDepartmentIds(res.departments.map(dep => dep.id.toString()));
}
})
.catch(e => console.log(e));
};
const departmentsCrud = useDepartmentCrud({ fetchDepartments });
const departmentSectionsCrud = useDepartmentSectionCrud({ fetchDepartments });
useEffect(() => {
fetchDepartments();
}, []);
const openDepartmentModal = (
title: string,
isDepartmentSection: boolean,
element?: DepartmentSchema | DepartmentSectionSchema | DepartmentSectionBaseSchema,
) => {
modals.openContextModal({
modal: "departmentModal",
title,
withCloseButton: false,
innerProps: {
departmentsCrud,
departmentSectionsCrud,
isDepartmentSection,
element,
},
});
};
const onCreateDepartmentClick = (isSection: boolean, element?: DepartmentSectionBaseSchema) => {
const title: string = isSection ? "Создание отдела" : "Создание департамента";
openDepartmentModal(title, isSection, element);
};
const onUpdateDepartmentClick = (element: DepartmentSchema | DepartmentSectionSchema, isSection: boolean) => {
const title: string = isSection ? "Редактирование отдела" : "Редактирование департамента";
openDepartmentModal(title, isSection, element);
};
const onDeleteDepartmentClick = (element: DepartmentSchema | DepartmentSectionSchema, isSection: boolean) => {
modals.openConfirmModal({
title: "Удаление " + (isSection ? "отдела" : "департамента"),
children: (
<Text size="sm">
Вы уверены что хотите удалить {isSection ? "отдел" : "департамент"} {element.name}?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => {
if (isSection) {
departmentSectionsCrud.onDelete(element as DepartmentSectionSchema);
} else {
departmentsCrud.onDelete(element);
}
},
});
};
const onAddUserClick = (departmentSection: DepartmentSectionSchema) => {
modals.openContextModal({
modal: "addUserToDepartmentModal",
title: `Добавление пользователя в отдел ${departmentSection.name}`,
withCloseButton: false,
innerProps: {
departmentSection,
fetchDepartments,
},
});
}
const toggleAllDepartmentIds = () => {
if (departmentIds.length > 0) {
setDepartmentIds([]);
return;
}
setDepartmentIds(departments.map(department => department.id.toString()));
}
return {
departments,
departmentIds,
setDepartmentIds,
fetchDepartments,
onCreateDepartmentClick,
onUpdateDepartmentClick,
onDeleteDepartmentClick,
onAddUserClick,
toggleAllDepartmentIds,
};
};
type DepartmentContextProviderProps = {
children: React.ReactNode;
};
export const DepartmentContextProvider: FC<DepartmentContextProviderProps> = ({ children }) => {
const state = useDepartmentContextState();
return (
<DepartmentContext.Provider value={state}>
{children}
</DepartmentContext.Provider>
);
};
export const useDepartmentContext = () => {
const context = useContext(DepartmentContext);
if (!context) {
throw new Error(
"useDepartmentContext must be used within a DepartmentContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,16 @@
import { DepartmentService } from "../../../../../client";
import ObjectList from "../../../../../hooks/objectList.tsx";
type Props = {
sectionId: number;
}
const useAvailableUsersList = ({ sectionId }: Props) =>
ObjectList({
queryFn: () => DepartmentService.getAvailableUsersForSection({ sectionId }),
getObjectsFn: response => response.users,
queryKey: "getAvailableUsersForDepartmentSection",
});
export default useAvailableUsersList;

View File

@@ -0,0 +1,49 @@
import { useCRUD } from "../../../../../hooks/useCRUD.tsx";
import { DepartmentBaseSchema, DepartmentSchema, DepartmentService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
export type DepartmentCrud = {
onCreate: (element: DepartmentBaseSchema) => void,
onDelete: (element: DepartmentSchema) => void,
onChange: (element: DepartmentSchema) => void
}
type Props = {
fetchDepartments: () => void;
}
const useDepartmentCrud = ({ fetchDepartments }: Props): DepartmentCrud => {
return useCRUD<DepartmentSchema, DepartmentBaseSchema>({
onChange: department => {
DepartmentService.updateDepartment({
requestBody: { department },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
onCreate: department => {
DepartmentService.createDepartment({
requestBody: { department },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
onDelete: department => {
DepartmentService.deleteDepartment({ departmentId: department.id })
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
});
};
export default useDepartmentCrud;

View File

@@ -0,0 +1,49 @@
import { useCRUD } from "../../../../../hooks/useCRUD.tsx";
import { DepartmentSectionBaseSchema, DepartmentSectionSchema, DepartmentService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
export type DepartmentSectionCrud = {
onCreate: (element: DepartmentSectionBaseSchema) => void,
onDelete: (element: DepartmentSectionSchema) => void,
onChange: (element: DepartmentSectionSchema) => void
}
type Props = {
fetchDepartments: () => void;
}
const useDepartmentSectionCrud = ({ fetchDepartments }: Props): DepartmentSectionCrud => {
return useCRUD<DepartmentSectionSchema, DepartmentSectionBaseSchema>({
onChange: section => {
DepartmentService.updateSection({
requestBody: { section },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
onCreate: section => {
DepartmentService.createSection({
requestBody: { section },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
onDelete: section => {
DepartmentService.deleteSection({ sectionId: section.id })
.then(({ ok, message }) => {
notifications.guess(ok, { message });
fetchDepartments();
})
.catch(err => console.log(err));
},
});
};
export default useDepartmentSectionCrud;

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { UserSchema } from "../../../../../client";
const useUsersTableColumns = () => {
return useMemo<MRT_ColumnDef<UserSchema>[]>(
() => [
{
header: "ФИО",
Cell: ({ row }) =>
`${row.original.secondName} ${row.original.firstName} ${row.original.patronymic}`,
},
{
accessorKey: "role.name",
header: "Роль",
},
{
accessorKey: "position.name",
header: "Должность",
},
{
accessorKey: "comment",
header: "Доп. информация",
}
],
[],
);
};
export default useUsersTableColumns;

View File

@@ -0,0 +1,64 @@
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, rem } from "@mantine/core";
import { DepartmentSectionSchema, DepartmentService } from "../../../../../client";
import AddUserToDepartmentModalForm from "../types/AddUserToDepartmentModalForm.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import UserForDepartmentSelect from "../components/UserForDepartmentSelect.tsx";
type Props = {
departmentSection: DepartmentSectionSchema;
fetchDepartments: () => void;
}
const AddUserToDepartmentModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const form = useForm<Partial<AddUserToDepartmentModalForm>>({
validate: {
user: user => !user && "Необходимо выбрать пользователя",
},
});
const onSubmit = () => {
if (!form.values.user) return;
DepartmentService.addUser({
requestBody: {
userId: form.values.user.id,
sectionId: innerProps.departmentSection.id,
}
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
innerProps.fetchDepartments();
})
.catch(err => console.log(err));
context.closeContextModal(id);
};
return (
<form onSubmit={form.onSubmit(() => onSubmit())}>
<Flex
direction={"column"}
gap={rem(10)}
>
<UserForDepartmentSelect
label={"Пользователь"}
{...form.getInputProps("user")}
sectionId={innerProps.departmentSection.id}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Flex>
</form>
);
};
export default AddUserToDepartmentModal;

View File

@@ -0,0 +1,79 @@
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, rem, TextInput } from "@mantine/core";
import { DepartmentModalForm } from "../types/DepartmentModalForm.tsx";
import { DepartmentSchema, DepartmentSectionBaseSchema, DepartmentSectionSchema } from "../../../../../client";
import { DepartmentCrud } from "../hooks/useDepartmentCrud.tsx";
import { DepartmentSectionCrud } from "../hooks/useDepartmentSectionCrud.tsx";
type Props = {
departmentsCrud: DepartmentCrud;
departmentSectionsCrud: DepartmentSectionCrud;
element?: DepartmentSchema | DepartmentSectionSchema | DepartmentSectionBaseSchema;
isDepartmentSection: boolean;
}
const DepartmentModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const {
departmentsCrud,
departmentSectionsCrud,
element,
isDepartmentSection,
} = innerProps;
const initialValues: DepartmentModalForm = {
name: element?.name ?? "",
};
const form = useForm<DepartmentModalForm>({
initialValues,
validate: {
name: name => !name && "Необходимо указать название",
},
});
const onSubmit = () => {
if (isDepartmentSection) {
const sectionData = { ...element as DepartmentSectionSchema, ...form.values };
if (sectionData.id) {
departmentSectionsCrud.onChange(sectionData);
} else {
departmentSectionsCrud.onCreate(sectionData);
}
} else {
const departmentData = element as DepartmentSchema;
if (element) {
departmentsCrud.onChange({ ...form.values, id: departmentData.id });
} else {
departmentsCrud.onCreate(form.values);
}
}
context.closeContextModal(id);
};
return (
<form onSubmit={form.onSubmit(() => onSubmit())}>
<Flex
direction={"column"}
gap={rem(10)}
>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Flex>
</form>
);
};
export default DepartmentModal;

View File

@@ -0,0 +1,7 @@
import { UserSchema } from "../../../../../client";
type AddUserToDepartmentModalForm = {
user: UserSchema;
}
export default AddUserToDepartmentModalForm;

View File

@@ -0,0 +1,3 @@
export type DepartmentModalForm = {
name: string;
}