This commit is contained in:
2024-07-22 12:46:12 +03:00
parent af05b51d1c
commit 23dbff2891
46 changed files with 1173 additions and 40 deletions

View File

@@ -1,10 +1,11 @@
import styles from './AdminPage.module.css';
import {Tabs} from "@mantine/core";
import PageBlock from "../../components/PageBlock/PageBlock.tsx";
import {IconBriefcase, IconUser} from "@tabler/icons-react";
import {IconBriefcase, IconCurrencyDollar, 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";
const AdminPage = () => {
@@ -16,13 +17,15 @@ const AdminPage = () => {
<Tabs.Tab value={"users"} leftSection={<IconUser/>}>
Пользователи
</Tabs.Tab>
<Tabs.Tab value={"finances"} leftSection={<IconCurrencyDollar/>}>
Финансы
</Tabs.Tab>
<Tabs.Tab value={"rolesAndPositions"} leftSection={<IconBriefcase/>}>
Должности
</Tabs.Tab>
{/*<Tabs.Tab value={"employees"} leftSection={<IconUsersGroup/>}>*/}
{/* Сотрудники*/}
{/*</Tabs.Tab>*/}
</Tabs.List>
<Tabs.Panel value={"users"}>
<motion.div
initial={{opacity: 0}}
@@ -31,7 +34,6 @@ const AdminPage = () => {
>
<UsersTab/>
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"rolesAndPositions"}>
@@ -43,6 +45,15 @@ const AdminPage = () => {
<RolesAndPositionsTab/>
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"finances"}>
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.2}}
>
<FinancesTab/>
</motion.div>
</Tabs.Panel>
</Tabs>
</PageBlock>

View File

@@ -0,0 +1,94 @@
import {CRUDTableProps} from "../../../../types/CRUDTable.tsx";
import {PayRateSchema} from "../../../../client";
import {FC} from "react";
import {BaseTable} from "../../../../components/BaseTable/BaseTable.tsx";
import {usePayRatesTableColumns} from "./columns.tsx";
import {ActionIcon, Button, Flex, rem, Text, Tooltip} from "@mantine/core";
import {modals} from "@mantine/modals";
import {IconEdit, IconTrash} from "@tabler/icons-react";
import {MRT_TableOptions} from "mantine-react-table";
type Props = CRUDTableProps<PayRateSchema>;
const PayRateTable: FC<Props> = ({items, onCreate, onChange, onDelete}) => {
const columns = usePayRatesTableColumns();
const onCreateClick = () => {
if (!onCreate) return;
modals.openContextModal({
modal: "payRateForm",
withCloseButton: false,
innerProps: {
onCreate: onCreate
}
})
}
const onEditClick = (payRate: PayRateSchema) => {
if (!onChange) return;
modals.openContextModal({
modal: "payRateForm",
withCloseButton: false,
innerProps: {
onChange: (event) => onChange({...event, id: payRate.id}),
element: payRate
}
})
}
const onDeleteClick = (payRate: PayRateSchema) => {
if (!onDelete) return;
modals.openConfirmModal({
title: 'Удаление тарифа',
children: (
<Text size="sm">
Вы уверены что хотите удалить тариф {payRate.name}
</Text>
),
labels: {confirm: 'Да', cancel: "Нет"},
confirmProps: {color: 'red'},
onConfirm: () => onDelete(payRate)
});
}
return (
<BaseTable
data={items}
columns={columns}
restProps={{
enableSorting: false,
enableColumnActions: false,
enableTopToolbar: true,
renderTopToolbar: (
<Flex p={rem(10)}>
<Button
variant={"default"}
onClick={() => onCreateClick()}
>
Создать тариф
</Button>
</Flex>
),
enableRowActions: true,
renderRowActions: ({row}) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit/>
</ActionIcon>
</Tooltip>
<Tooltip label="Удалить">
<ActionIcon onClick={() => onDeleteClick(row.original)} variant={"default"}>
<IconTrash/>
</ActionIcon>
</Tooltip>
</Flex>
)
} as MRT_TableOptions<PayRateSchema>}
/>
)
}
export default PayRateTable;

View File

@@ -0,0 +1,32 @@
import {useMemo} from "react";
import {MRT_ColumnDef} from "mantine-react-table";
import {PayRateSchema} from "../../../../client";
export const usePayRatesTableColumns = () => {
return useMemo<MRT_ColumnDef<PayRateSchema>[]>(() => [
{
accessorKey: "name",
header: "Название тарифа"
},
{
accessorKey: "payrollScheme.name",
header: "Система оплаты"
},
{
accessorKey: "baseRate",
header: "Базовая ставка",
Cell: ({row}) => `${row.original.baseRate.toLocaleString("ru")}`
},
{
accessorKey: "overtimeThreshold",
header: "Порог сверхурочных"
},
{
accessorKey: "overtimeRate",
header: "Сверхурочная ставка",
Cell: ({row}) => row.original.overtimeRate && `${row.original.overtimeRate.toLocaleString("ru")}`
}
], []);
}

View File

@@ -0,0 +1,123 @@
import {FC, useEffect, useState} from "react";
import {ActionIcon, Button, Flex, Pagination, rem, Text, Tooltip} from "@mantine/core";
import {usePaymentRecordsList} from "../../../../hooks/usePaymentRecordsList.tsx";
import {BaseTable} from "../../../../components/BaseTable/BaseTable.tsx";
import {usePaymentRecordsTableColumns} from "./columns.tsx";
import {modals} from "@mantine/modals";
import {PaymentRecordCreateSchema, PaymentRecordGetSchema, PayrollService} from "../../../../client";
import {notifications} from "../../../../shared/lib/notifications.ts";
import {IconTrash} from "@tabler/icons-react";
import {MRT_TableOptions} from "mantine-react-table";
import {formatDate} from "../../../../types/utils.ts";
const PaymentRecordsTable: FC = () => {
const [totalPages, setTotalPages] = useState(10);
const [page, setPage] = useState(1);
const {
pagination: paginationInfo,
objects: paymentRecords,
refetch
} = usePaymentRecordsList({page: page, itemsPerPage: 10});
useEffect(() => {
if (!paginationInfo) return;
setTotalPages(paginationInfo.totalPages);
}, [paginationInfo]);
const onCreate = (request: PaymentRecordCreateSchema) => {
PayrollService.createPaymentRecord({
requestBody: {
data: request
}
}).then(async ({ok, message}) => {
notifications.guess(ok, {message});
if (!ok) return;
await refetch();
})
}
const onCreateClick = () => {
modals.openContextModal({
modal: "createPaymentRecord",
title: "Создание начисления",
innerProps: {
onCreate: onCreate
},
})
}
const onDelete = (record: PaymentRecordGetSchema) => {
PayrollService.deletePaymentRecord({
requestBody: {
paymentRecordId: record.id
}
}).then(async ({ok, message}) => {
notifications.guess(ok, {message});
if (!ok) return;
await refetch();
})
}
const onDeleteClick = (record: PaymentRecordGetSchema) => {
modals.openConfirmModal({
title: 'Удаление начисления',
children: (
<Text size="sm">
Вы уверены что хотите удалить начисление
пользователю {record.user.firstName} {record.user.secondName} от {formatDate(record.createdAt)}
</Text>
),
labels: {confirm: 'Да', cancel: "Нет"},
confirmProps: {color: 'red'},
onConfirm: () => onDelete(record)
})
}
const columns = usePaymentRecordsTableColumns();
return (
<Flex
direction={"column"}
h={"100%"}
gap={rem(10)}
>
<BaseTable
data={paymentRecords}
columns={columns}
restProps={{
enableSorting: false,
enableColumnActions: false,
enableTopToolbar: true,
renderTopToolbar: (
<Flex p={rem(10)}>
<Button
variant={"default"}
onClick={() => onCreateClick()}
>
Создать начисление
</Button>
</Flex>
),
enableRowActions: true,
renderRowActions: ({row}) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon onClick={() => onDeleteClick(row.original)} variant={"default"}>
<IconTrash/>
</ActionIcon>
</Tooltip>
</Flex>
)
} as MRT_TableOptions<PaymentRecordGetSchema>}
/>
{totalPages > 1 &&
<Pagination
style={{alignSelf: "flex-end"}}
withEdges
onChange={event => setPage(event)}
value={page}
total={totalPages}
/>
}
</Flex>
)
}
export default PaymentRecordsTable;

View File

@@ -0,0 +1,58 @@
import {useMemo} from "react";
import {MRT_ColumnDef} from "mantine-react-table";
import {PaymentRecordGetSchema} from "../../../../client";
import {PaySchemeType} from "../../../../shared/enums/PaySchemeType.ts";
import {getPluralForm} from "../../../../shared/lib/utils.ts";
import {formatDate} from "../../../../types/utils.ts";
import {isEqual} from "lodash";
export const usePaymentRecordsTableColumns = () => {
const getWorkUnitsText = (paymentRecord: PaymentRecordGetSchema) => {
const payrollScheme = paymentRecord.payrollScheme;
if (payrollScheme.key === PaySchemeType.HOURLY) {
return getPluralForm(paymentRecord.workUnits, "час", "часа", "часов")
} else if (
payrollScheme.key === PaySchemeType.DAILY
) {
return getPluralForm(paymentRecord.workUnits, "день", "дня", "дней")
} else if (
payrollScheme.key === PaySchemeType.MONTHLY
) {
return getPluralForm(paymentRecord.workUnits, "месяц", "месяца", "месяцев");
}
return "";
}
const getDateRangesText = (paymentRecord: PaymentRecordGetSchema) => {
if (paymentRecord.endDate && !isEqual(paymentRecord.startDate, paymentRecord.endDate)) {
return `${formatDate(paymentRecord.startDate)} - ${formatDate(paymentRecord.endDate)}`
}
return `${formatDate(paymentRecord.startDate)}`;
}
return useMemo<MRT_ColumnDef<PaymentRecordGetSchema>[]>(() => [
{
header: "Дата начисления",
Cell: ({row}) => new Date(row.original.createdAt).toLocaleString('ru-RU')
},
{
header: "Получил начисление",
Cell: ({row}) => `${row.original.user.firstName} ${row.original.user.secondName}`
},
{
header: "Создал начисление",
Cell: ({row}) => `${row.original.createdByUser.firstName} ${row.original.createdByUser.secondName}`
},
{
header: "Количество",
Cell: ({row}) => `${row.original.workUnits} ${getWorkUnitsText(row.original)}`
},
{
header: "Сумма начисления",
Cell: ({row}) => row.original.amount.toLocaleString("ru-RU")
},
{
header: "Временной промежуток",
Cell: ({row}) => getDateRangesText(row.original)
}
], [])
}

View File

@@ -52,13 +52,7 @@ const UsersTable: FC<Props> = ({items, onChange, onDelete}) => {
enableRowActions: true,
renderRowActions: ({row}) => (
<Flex gap="md">
<Tooltip onClick={() => {
onDeleteClick(row.original);
}} label="Удалить">
<ActionIcon variant={"default"}>
<IconTrash/>
</ActionIcon>
</Tooltip>
<Tooltip
onClick={() => {
onEditClick(row.original)
@@ -69,6 +63,13 @@ const UsersTable: FC<Props> = ({items, onChange, onDelete}) => {
<IconEdit/>
</ActionIcon>
</Tooltip>
<Tooltip onClick={() => {
onDeleteClick(row.original);
}} label="Удалить">
<ActionIcon variant={"default"}>
<IconTrash/>
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<UserSchema>}

View File

@@ -5,17 +5,10 @@ import {IconCheck, IconX} from "@tabler/icons-react";
export const useUsersTableColumns = () => {
return useMemo<MRT_ColumnDef<UserSchema>[]>(() => [
{
accessorKey: "firstName",
header: "Имя"
},
{
accessorKey: "secondName",
header: "Фамилия"
},
{
accessorKey: "telegramId",
header: "ID Телеграм"
header: "ФИО",
Cell: ({row}) => `${row.original.firstName} ${row.original.secondName}`
},
{
accessorKey: "phoneNumber",
@@ -25,6 +18,14 @@ export const useUsersTableColumns = () => {
accessorKey: "role.name",
header: "Роль"
},
{
accessorKey: "position.name",
header: "Должность"
},
{
accessorKey: "payRate.name",
header: "Тариф"
},
{
accessorKey: "comment",
header: "Дополнительная информация"

View File

@@ -0,0 +1,9 @@
import {PayrollService} from "../../../client";
import ObjectList from "../../../hooks/objectList.tsx";
const usePayRatesList = () => ObjectList({
queryFn: PayrollService.getAllPayRates,
getObjectsFn: response => response.payRates,
queryKey: "getAllPayRates"
})
export default usePayRatesList;

View File

@@ -0,0 +1,130 @@
import BaseFormModal, {CreateProps} from "../../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import {PaymentRecordCreateSchema} from "../../../../client";
import {ContextModalProps} from "@mantine/modals";
import {useForm} from "@mantine/form";
import {Flex, NumberInput, rem} from "@mantine/core";
import {DatePickerInput, MonthPickerInput} from "@mantine/dates";
import {useEffect, useState} from "react";
import {dateWithoutTimezone} from "../../../../shared/lib/utils.ts";
import UserSelect from "../../../../components/Selects/UserSelect/UserSelect.tsx";
import {PaySchemeType} from "../../../../shared/enums/PaySchemeType.ts";
import {motion} from "framer-motion";
type Props = CreateProps<PaymentRecordCreateSchema>;
const CreatePaymentRecordModal = ({
context,
id,
innerProps
}: ContextModalProps<Props>) => {
const form = useForm<Partial<PaymentRecordCreateSchema>>({
validate: {
user: (user) => !user && "Необходимо выбрать сотрудника",
startDate: (startDate) => !startDate && "Необходимо указать временной промежуток",
workUnits: (workUnits) => !workUnits && "Укажите количество"
}
})
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([null, null]);
useEffect(() => {
const setDates = (start: string | undefined, end: string | undefined) => {
form.setFieldValue("startDate", start);
form.setFieldValue("endDate", end);
};
if (dateRange.every(dr => dr == null)) {
setDates(undefined, undefined);
return
} else {
const notNullValues = dateRange.filter((dr): dr is Date => dr !== null).map(dateWithoutTimezone);
const startDate = notNullValues[0];
const endDate = notNullValues[1] || startDate;
setDates(startDate, endDate);
}
}, [dateRange]);
const getDateRangeInput = () => {
if (!form.values.user) return <></>
const payRate = form.values.user.payRate;
if (!payRate) return <></>;
if (payRate.payrollScheme.key == PaySchemeType.MONTHLY)
return (
<MonthPickerInput
error={form.getInputProps("startDate").error}
label={"Временной промежуток"}
placeholder={"Выберите временной промежуток"}
type={"range"}
value={dateRange}
onChange={setDateRange}
allowSingleDateInRange
/>
)
return (<DatePickerInput
error={form.getInputProps("startDate").error}
label={"Временной промежуток"}
placeholder={"Выберите временной промежуток"}
type={"range"}
allowSingleDateInRange
value={dateRange}
onChange={setDateRange}
/>);
}
const getAmountLabel = () => {
const user = form.values.user;
if (!user) return "";
const payRate = user?.payRate;
if (!payRate) return "";
if (payRate.payrollScheme.key == PaySchemeType.HOURLY) return "Количество часов";
if (payRate.payrollScheme.key == PaySchemeType.MONTHLY) return "Количество месяцев";
if (payRate.payrollScheme.key == PaySchemeType.DAILY) return "Количество дней";
return "";
}
const getAmountPlaceholder = () => {
return "Укажите " + getAmountLabel().toLowerCase();
}
return (<BaseFormModal
form={form}
closeOnSubmit
onClose={() => context.closeContextModal(id)}
{...innerProps}
>
<BaseFormModal.Body>
<>
<Flex direction={"column"} gap={rem(10)}>
<UserSelect
label={"Сотрудник"}
placeholder={"Выберите сотрудника"}
searchable
filterBy={(user) => !!user.payRate}
{...form.getInputProps("user")}
/>
{form.values.user &&
<>
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.3}}
>
<Flex
direction={"column"}
gap={rem(10)}>
{getDateRangeInput()}
<NumberInput
label={getAmountLabel()}
placeholder={getAmountPlaceholder()}
hideControls
{...form.getInputProps("workUnits")}
/>
</Flex>
</motion.div>
</>
}
</Flex>
</>
</BaseFormModal.Body>
</BaseFormModal>)
}
export default CreatePaymentRecordModal;

View File

@@ -0,0 +1,98 @@
import BaseFormModal, {CreateEditFormProps} from "../../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import {PayRateSchemaBase} from "../../../../client";
import {ContextModalProps} from "@mantine/modals";
import {useForm} from "@mantine/form";
import {Fieldset, Flex, NumberInput, rem, TextInput} from "@mantine/core";
import PayrollSchemeSelect from "../../../../components/Selects/PayrollSchemeSelect/PayrollSchemeSelect.tsx";
import {PaySchemeType} from "../../../../shared/enums/PaySchemeType.ts";
type Props = CreateEditFormProps<PayRateSchemaBase>
const PayRateFormModal = ({
context,
id,
innerProps
}: ContextModalProps<Props>) => {
const isEditing = 'element' in innerProps;
const initialValue: Partial<PayRateSchemaBase> = isEditing ? innerProps.element : {};
const form = useForm<Partial<PayRateSchemaBase>>({
initialValues: initialValue,
validate: {
name: (name) => !name && "Необходимо указать название тарифа",
payrollScheme: (scheme) => !scheme && "Необходимо выбрать систему оплаты",
baseRate: (baseRate) => !baseRate && "Небходимо указать базовую ставку"
}
});
return (
<BaseFormModal
form={form}
closeOnSubmit
onClose={() => context.closeContextModal(id)}
{...innerProps}
>
<BaseFormModal.Body>
<>
<Fieldset legend={"Общие параметры"}>
<Flex direction={"column"} gap={rem(10)}>
<TextInput
label={"Название"}
placeholder={"Введите название тарифа"}
{...form.getInputProps("name")}
/>
<PayrollSchemeSelect
label={"Система оплаты"}
placeholder={"Выберите систему оплаты"}
{...form.getInputProps("payrollScheme")}
/>
</Flex>
</Fieldset>
<Fieldset>
<Flex direction={"column"} gap={rem(10)}>
<NumberInput
allowNegative={false}
hideControls
decimalScale={2}
label={"Базовая ставка"}
placeholder={"Выберите базовую ставку"}
thousandSeparator={" "}
suffix={"₽"}
{...form.getInputProps("baseRate")}
/>
{form.values.payrollScheme?.key === PaySchemeType.HOURLY &&
<>
<NumberInput
allowNegative={false}
hideControls
allowDecimal={false}
label={"Порог сверхурочных"}
placeholder={"Введите порог сверхурочных"}
{...form.getInputProps("overtimeThreshold")}
/>
<NumberInput
allowNegative={false}
hideControls
decimalScale={2}
label={"Сверхурочная ставка"}
placeholder={"Выберите сверхурочную ставку"}
thousandSeparator={" "}
suffix={"₽"}
{...form.getInputProps("overtimeRate")}
/>
</>
}
</Flex>
</Fieldset>
</>
</BaseFormModal.Body>
</BaseFormModal>
)
}
export default PayRateFormModal;

View File

@@ -9,6 +9,7 @@ import {UserRoleEnum} from "../../../../shared/enums/UserRole.ts";
import {capitalize} from "lodash";
import {IMaskInput} from "react-imask";
import phone from "phone";
import PayRateSelect from "../../../../components/Selects/PayRateSelect/PayRateSelect.tsx";
type Props = EditProps<UserSchema>;
const UserFormModal = ({context, id, innerProps}: ContextModalProps<Props>) => {
@@ -65,21 +66,27 @@ const UserFormModal = ({context, id, innerProps}: ContextModalProps<Props>) => {
</Fieldset>
<Fieldset legend={"Роль и должность"}>
<Stack>
<RoleSelect
label={"Роль пользователя"}
placeholder={"Выберите роль пользователя"}
{...form.getInputProps('role')}
/>
{form.values.role.key === UserRoleEnum.EMPLOYEE &&
<PositionSelect
label={"Должность сотрудника"}
placeholder={"Выберите должность сотрудника"}
{...form.getInputProps('position')}
/>
<>
<PositionSelect
label={олжность сотрудника"}
placeholder={"Выберите должность сотрудника"}
{...form.getInputProps('position')}
/>
<PayRateSelect
label={"Тариф"}
placeholder={"Выберите тариф сотрудника"}
{...form.getInputProps("payRate")}
/>
</>
}
</Stack>
</Fieldset>
<Fieldset legend={"Дополнительные параметры"}>
<Stack>

View File

@@ -0,0 +1,97 @@
import {Tabs} from "@mantine/core";
import {IconBusinessplan, IconHistory} from "@tabler/icons-react";
import {motion} from "framer-motion";
import PayRateTable from "../../components/PayRateTable/PayRateTable.tsx";
import usePayRatesList from "../../hooks/usePayRatesList.tsx";
import {CRUDTableProps} from "../../../../types/CRUDTable.tsx";
import {PayRateSchema, PayRateSchemaBase, PayrollService} from "../../../../client";
import {notifications} from "../../../../shared/lib/notifications.ts";
import PaymentRecordsTable from "../../components/PaymentRecordsTable/PaymentRecordsTable.tsx";
const payRateTableState = (): CRUDTableProps<PayRateSchema> => {
const {objects: items, refetch} = usePayRatesList();
const onCreate = (item: PayRateSchemaBase) => {
PayrollService.createPayRate({
requestBody: {
data: item
}
}).then(async ({ok, message}) => {
notifications.guess(ok, {message});
if (!ok) return;
await refetch();
});
}
const onChange = (item: PayRateSchema) => {
PayrollService.updatePayRate({
requestBody: {
data: item
}
}).then(async ({ok, message}) => {
notifications.guess(ok, {message});
if (!ok) return;
await refetch();
});
}
const onDelete = (item: PayRateSchema) => {
PayrollService.deletePayRate({
requestBody: {
payRateId: item.id
}
}).then(async ({ok, message}) => {
notifications.guess(ok, {message});
if (!ok) return;
await refetch();
});
}
return {items, onCreate, onChange, onDelete};
}
const FinancesTab = () => {
const payRateState = payRateTableState();
return (
<>
<Tabs
keepMounted={false}
defaultValue={"paymentRecords"}
color={"gray.6"}
>
<Tabs.List
justify={"center"}
grow
>
<Tabs.Tab value={"paymentRecords"} leftSection={<IconHistory/>}>
Начисления
</Tabs.Tab>
<Tabs.Tab value={"tariffs"} leftSection={<IconBusinessplan/>}>
Тарифы
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={"tariffs"}>
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.2}}
>
<PayRateTable
{...payRateState}
/>
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"paymentRecords"}>
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.2}}
>
<PaymentRecordsTable/>
</motion.div>
</Tabs.Panel>
</Tabs>
</>
)
}
export default FinancesTab