feat: modules and module-editor pages

This commit is contained in:
2025-10-25 12:11:14 +04:00
parent 57a7ab0871
commit 2bdbebc453
40 changed files with 3485 additions and 38 deletions

View File

@ -16,6 +16,7 @@ import {
import { theme } from "@/theme";
import "@/app/global.css";
import { ContextMenuProvider } from "mantine-contextmenu";
import { DatesProvider } from "@mantine/dates";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
@ -71,31 +72,33 @@ export default function RootLayout({ children }: Props) {
<ModalsProvider
labels={{ confirm: "Да", cancel: "Нет" }}
modals={modals}>
<DrawersContextProvider>
<ProjectsContextProvider>
<AppShell
layout={"alt"}
withBorder={false}
navbar={{
width: 220,
breakpoint: "sm",
collapsed: {
desktop: false,
mobile: true,
},
}}>
<AppShellNavbarWrapper>
<Navbar />
</AppShellNavbarWrapper>
<AppShellMainWrapper>
{children}
</AppShellMainWrapper>
<AppShellFooterWrapper>
<Footer />
</AppShellFooterWrapper>
</AppShell>
</ProjectsContextProvider>
</DrawersContextProvider>
<DatesProvider settings={{ locale: "ru" }}>
<DrawersContextProvider>
<ProjectsContextProvider>
<AppShell
layout={"alt"}
withBorder={false}
navbar={{
width: 220,
breakpoint: "sm",
collapsed: {
desktop: false,
mobile: true,
},
}}>
<AppShellNavbarWrapper>
<Navbar />
</AppShellNavbarWrapper>
<AppShellMainWrapper>
{children}
</AppShellMainWrapper>
<AppShellFooterWrapper>
<Footer />
</AppShellFooterWrapper>
</AppShell>
</ProjectsContextProvider>
</DrawersContextProvider>
</DatesProvider>
</ModalsProvider>
</ReduxProvider>
<Notifications position="bottom-right" />

View File

@ -0,0 +1,12 @@
.container {
padding: var(--mantine-spacing-sm);
border: dashed 1px var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
}
.module-attr-container {
@mixin light {
background-color: var(--color-light-gray-blue);
border: 1px dashed var(--color-light-aqua);
}
}

View File

@ -0,0 +1,68 @@
import { FC, useCallback, useMemo } from "react";
import {
IconArrowRight,
IconEdit,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { Center, Flex } from "@mantine/core";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { AttributeSchema } from "@/lib/client";
type Props = {
attribute: AttributeSchema;
};
const AttributeTableActions: FC<Props> = ({ attribute }) => {
const { attributeActions, module } = useModuleEditorContext();
const usedAttributeIds = useMemo(
() => new Set(module?.attributes.map(a => a.id)),
[module]
);
const toggleAttributeInModule = useCallback(
(attribute: AttributeSchema) => {
if (usedAttributeIds.has(attribute.id)) {
attributeActions.removeAttributeFromModule(attribute);
} else {
attributeActions.addAttributeToModule(attribute);
}
},
[usedAttributeIds]
);
return (
<Center>
<Flex gap={"xs"}>
<ActionIconWithTip
onClick={() => attributeActions.onUpdate(attribute)}
disabled={attribute.isBuiltIn}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
<ActionIconWithTip
onClick={() => attributeActions.onDelete(attribute)}
disabled={attribute.isBuiltIn}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
<ActionIconWithTip
onClick={() => toggleAttributeInModule(attribute)}
tipLabel={
usedAttributeIds.has(attribute.id)
? "Удалить из модуля"
: "Добавить в модуль"
}>
{usedAttributeIds.has(attribute.id) ? (
<IconX />
) : (
<IconArrowRight />
)}
</ActionIconWithTip>
</Flex>
</Center>
);
};
export default AttributeTableActions;

View File

@ -0,0 +1,21 @@
import useAttributeTypesList from "@/app/module-editor/[moduleId]/hooks/useAttributeTypesList";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { AttributeTypeSchema } from "@/lib/client";
type Props = Omit<ObjectSelectProps<AttributeTypeSchema>, "data">;
const AttributeTypeSelect = (props: Props) => {
const { attributeTypes } = useAttributeTypesList();
return (
<ObjectSelect
label={"Тип атрибута"}
data={attributeTypes}
{...props}
/>
);
};
export default AttributeTypeSelect;

View File

@ -0,0 +1,34 @@
import { Box, Center, Text } from "@mantine/core";
import useAttributesTableColumns from "@/app/module-editor/[moduleId]/components/shared/AttributesTable/useAttributesTableColumns";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
const AttributesTable = () => {
const { attributes } = useModuleEditorContext();
const columns = useAttributesTableColumns();
if (attributes.length === 0) {
return (
<Center my={"md"}>
<Text>Нет атрибутов</Text>
</Center>
);
}
return (
<Box
h="100%"
style={{ overflow: "auto" }}>
<BaseTable
withTableBorder
columns={columns}
records={attributes}
verticalSpacing={"md"}
groups={undefined}
styles={{ table: { width: "100%" } }}
/>
</Box>
);
};
export default AttributesTable;

View File

@ -0,0 +1,37 @@
"use client";
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { Center } from "@mantine/core";
import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttributeSchema } from "@/lib/client";
const useAttributesTableColumns = () => {
const isMobile = useIsMobile();
return useMemo(
() =>
[
{
title: "Название",
accessor: "label",
},
{
title: "Тип",
accessor: "type.name",
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: attribute => (
<AttributeTableActions attribute={attribute} />
),
},
] as DataTableColumn<AttributeSchema>[],
[isMobile]
);
};
export default useAttributesTableColumns;

View File

@ -0,0 +1,18 @@
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
const CreateAttributeButton = () => {
const {
attributeActions: { onCreate },
} = useModuleEditorContext();
return (
<InlineButton
onClick={onCreate}
w={"100%"}>
Создать атрибут
</InlineButton>
);
};
export default CreateAttributeButton;

View File

@ -0,0 +1,90 @@
import { Checkbox, NumberInput, TextInput } from "@mantine/core";
import { DatePickerInput, DateTimePicker } from "@mantine/dates";
import { UseFormReturnType } from "@mantine/form";
import { UpdateAttributeSchema } from "@/lib/client";
type Props = {
form: UseFormReturnType<Partial<UpdateAttributeSchema>>;
};
const DefaultAttributeValueInput = ({ form }: Props) => {
const type = form.values.type?.type;
const label = "Значение по умолчанию";
const inputName = "defaultValue";
const value = form.getValues().defaultValue?.value;
if (type === "bool") {
return (
<Checkbox
label={label}
{...form.getInputProps(inputName, { type: "checkbox" })}
checked={value as boolean}
onChange={e =>
form.setFieldValue("defaultValue", {
value: e.currentTarget.checked,
})
}
/>
);
} else if (type === "date") {
return (
<DatePickerInput
label={label}
{...form.getInputProps(inputName)}
value={
form.values.defaultValue?.value
? new Date(String(form.values.defaultValue.value))
: null
}
onChange={value =>
form.setFieldValue("defaultValue", { value })
}
clearable
locale={"ru"}
valueFormat={"DD.MM.YYYY"}
/>
);
} else if (type === "datetime") {
return (
<DateTimePicker
label={label}
{...form.getInputProps(inputName)}
value={
form.values.defaultValue?.value
? new Date(String(form.values.defaultValue.value))
: null
}
onChange={value =>
form.setFieldValue("defaultValue", { value })
}
clearable
locale={"ru"}
valueFormat={"DD.MM.YYYY HH:mm"}
/>
);
} else if (type === "str") {
return (
<TextInput
label={label}
{...form.getInputProps(inputName)}
/>
);
} else if (type === "int" || type === "float") {
return (
<NumberInput
allowDecimal={type === "float"}
label={label}
{...form.getInputProps(inputName)}
value={Number(form.values.defaultValue?.value)}
onChange={value =>
form.setFieldValue("defaultValue", { value: Number(value) })
}
/>
);
}
return <></>;
};
export default DefaultAttributeValueInput;

View File

@ -0,0 +1,68 @@
import { FC } from "react";
import { IconEdit, IconX } from "@tabler/icons-react";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import styles from "@/app/module-editor/[moduleId]/ModuleEditor.module.css";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { ModuleAttributeSchema } from "@/lib/client";
type Props = {
attribute: ModuleAttributeSchema;
};
const ModuleAttribute: FC<Props> = ({ attribute }) => {
const {
attributeActions: { removeAttributeFromModule, onEditAttributeLabel },
} = useModuleEditorContext();
const getAttrLabelText = () => {
const order = 6;
if (attribute.label === attribute.originalLabel) {
return <Title order={order}>Название: {attribute.label}</Title>;
}
return (
<Group gap={"xs"}>
<Title order={order}>Название: {attribute.label}</Title>{" "}
<Text>(Ориг. {attribute.originalLabel})</Text>
</Group>
);
};
return (
<Card
flex={1}
className={styles["module-attr-container"]}>
<Group
w={"100%"}
h={"100%"}
justify={"space-between"}
align={"center"}>
<Stack gap={7}>
<>{getAttrLabelText()}</>
<Text>Тип: {attribute.type.name}</Text>
</Stack>
<Group
justify={"end"}
wrap={"nowrap"}
w={"100%"}
gap={"xs"}>
<ActionIconWithTip
tipLabel={
"Редактировать название (только для данного модуля)"
}
onClick={() => onEditAttributeLabel(attribute)}>
<IconEdit />
</ActionIconWithTip>
<ActionIconWithTip
tipLabel={"Удалить из модуля"}
onClick={() => removeAttributeFromModule(attribute)}>
<IconX />
</ActionIconWithTip>
</Group>
</Group>
</Card>
);
};
export default ModuleAttribute;

View File

@ -0,0 +1,41 @@
import React, { ReactNode } from "react";
import { Flex } from "@mantine/core";
import ModuleAttribute from "@/app/module-editor/[moduleId]/components/shared/ModuleAttribute/ModuleAttribute";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import FormFlexRow from "@/components/ui/FormFlexRow/FormFlexRow";
const ModuleAttributesEditor = () => {
const { module } = useModuleEditorContext();
const getAttributesRows = () => {
if (!module?.attributes) return [];
const rows: ReactNode[] = [];
for (let i = 0; i < module.attributes.length; i += 2) {
const rightIdx = i + 1;
rows.push(
<FormFlexRow key={i}>
<ModuleAttribute attribute={module.attributes[i]} />
{rightIdx < module.attributes.length && (
<ModuleAttribute
attribute={module.attributes[rightIdx]}
/>
)}
</FormFlexRow>
);
}
return rows;
};
return (
<Flex
gap={"xs"}
direction={"column"}>
{getAttributesRows()}
</Flex>
);
};
export default ModuleAttributesEditor;

View File

@ -0,0 +1,52 @@
import { Button, Flex, Group, Textarea, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import { ModuleInfo } from "../../../hooks/useModulesActions";
const ModuleCommonInfoEditor = () => {
const {
module,
moduleActions: { updateCommonInfo },
} = useModuleEditorContext();
const form = useForm<ModuleInfo>({
initialValues: module ?? {
label: "",
description: "",
},
validate: {
label: label => !label && "Введите название модуля",
},
});
const onSubmit = (values: ModuleInfo) => {
updateCommonInfo(values, form.resetDirty);
};
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Flex
gap={"xs"}
direction={"column"}>
<TextInput
label={"Название"}
{...form.getInputProps("label")}
/>
<Textarea
label={"Описание"}
{...form.getInputProps("description")}
/>
<Group>
<Button
variant={"default"}
disabled={!form.isDirty()}
type={"submit"}>
Сохранить
</Button>
</Group>
</Flex>
</form>
);
};
export default ModuleCommonInfoEditor;

View File

@ -0,0 +1,59 @@
"use client";
import { Box, Fieldset, Flex, Stack } from "@mantine/core";
import AttributesTable from "@/app/module-editor/[moduleId]/components/shared/AttributesTable/AttributesTable";
import CreateAttributeButton from "@/app/module-editor/[moduleId]/components/shared/CreateAttributeButton/CreateAttributeButton";
import ModuleAttributesEditor from "@/app/module-editor/[moduleId]/components/shared/ModuleAttributesEditor/ModuleAttributesEditor";
import ModuleCommonInfoEditor from "@/app/module-editor/[moduleId]/components/shared/ModuleCommonInfoEditor/ModuleCommonInfoEditor";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
const PageBody = () => {
const { module } = useModuleEditorContext();
return (
<Stack h={"100%"}>
<PageBlock
style={{ flex: 1, minHeight: 0 }}
fullScreenMobile>
<Flex
w={"100%"}
h={"100%"}
flex={3}
style={{ overflow: "auto" }}
gap={"md"}>
<Fieldset
flex={1}
legend={"Атрибуты"}>
<Flex
direction={"column"}
h={"100%"}
gap={"xs"}>
<Box style={{ flex: 1, minHeight: 0 }}>
<AttributesTable />
</Box>
<CreateAttributeButton />
</Flex>
</Fieldset>
<Flex
gap={"xs"}
flex={2}
direction={"column"}>
<Fieldset
flex={1}
legend={"Общие данные модуля"}>
{module && <ModuleCommonInfoEditor />}
</Fieldset>
<Fieldset
flex={3}
legend={"Атрибуты модуля"}>
<ModuleAttributesEditor />
</Fieldset>
</Flex>
</Flex>
</PageBlock>
</Stack>
);
};
export default PageBody;

View File

@ -0,0 +1,71 @@
"use client";
import { useEffect } from "react";
import { redirect } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import useAttributesActions, {
AttributesActions,
} from "@/app/module-editor/[moduleId]/hooks/useAttributesActions";
import useAttributesList from "@/app/module-editor/[moduleId]/hooks/useAttributesList";
import useModulesActions, {
ModulesActions,
} from "@/app/module-editor/[moduleId]/hooks/useModulesActions";
import { AttributeSchema, ModuleWithAttributesSchema } from "@/lib/client";
import { getModuleWithAttributesOptions } from "@/lib/client/@tanstack/react-query.gen";
import makeContext from "@/lib/contextFactory/contextFactory";
type ModuleEditorContextState = {
module?: ModuleWithAttributesSchema;
refetchModule: () => void;
attributes: AttributeSchema[];
refetchAttributes: () => void;
attributeActions: AttributesActions;
moduleActions: ModulesActions;
};
type Props = {
moduleId: number;
};
const useModuleEditorContextState = ({
moduleId,
}: Props): ModuleEditorContextState => {
const { data, refetch: refetchModule } = useQuery(
getModuleWithAttributesOptions({ path: { pk: moduleId } })
);
const module = data?.entity;
const { attributes, refetch: refetchAttributes } = useAttributesList();
useEffect(() => {
if (module?.isBuiltIn) {
redirect("modules");
}
}, [module]);
const attributeActions = useAttributesActions({
module,
refetchModule,
refetchAttributes,
});
const moduleActions = useModulesActions({
module,
refetchModule,
});
return {
module,
refetchModule,
attributes,
refetchAttributes,
attributeActions,
moduleActions,
};
};
export const [ModuleEditorContextProvider, useModuleEditorContext] =
makeContext<ModuleEditorContextState, Props>(
useModuleEditorContextState,
"ModuleEditor"
);

View File

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getAttributeTypesOptions } from "@/lib/client/@tanstack/react-query.gen";
const useAttributeTypesList = () => {
const { data, refetch } = useQuery(getAttributeTypesOptions());
return {
attributeTypes: data?.items ?? [],
refetch,
};
};
export default useAttributeTypesList;

View File

@ -0,0 +1,214 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import {
AttributeSchema,
HttpValidationError,
ModuleSchemaOutput,
} from "@/lib/client";
import {
addAttributeToModuleMutation,
createAttributeMutation,
deleteAttributeMutation,
removeAttributeFromModuleMutation,
updateAttributeLabelMutation,
updateAttributeMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
export type AttributesActions = {
addAttributeToModule: (attribute: AttributeSchema) => void;
removeAttributeFromModule: (attribute: AttributeSchema) => void;
onEditAttributeLabel: (attribute: AttributeSchema) => void;
onCreate: () => void;
onUpdate: (attribute: AttributeSchema) => void;
onDelete: (attribute: AttributeSchema) => void;
};
type Props = {
module?: ModuleSchemaOutput;
refetchModule: () => void;
refetchAttributes: () => void;
};
const useAttributesActions = ({
module,
refetchModule,
refetchAttributes,
}: Props): AttributesActions => {
const onError = (error: AxiosError<HttpValidationError>) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
};
const addAttrToModuleMutation = useMutation({
...addAttributeToModuleMutation(),
onError,
});
const removeAttrFromModuleMutation = useMutation({
...removeAttributeFromModuleMutation(),
onError,
});
const toggleAttributeInModule = (
attribute: AttributeSchema,
isAdding: boolean
) => {
if (!module) return;
const mutation = isAdding
? addAttrToModuleMutation
: removeAttrFromModuleMutation;
mutation.mutate(
{
body: {
moduleId: module.id,
attributeId: attribute.id,
},
},
{
onSuccess: ({ message }) => {
notifications.success({ message });
refetchModule();
refetchAttributes();
},
}
);
};
const addAttributeToModule = (attribute: AttributeSchema) =>
toggleAttributeInModule(attribute, true);
const removeAttributeFromModule = (attribute: AttributeSchema) => {
modals.openConfirmModal({
title: "Удаление атрибута из модуля",
children: (
<Text>
Вы уверены, что хотите удалить атрибут "{attribute.label}"
из модуля?
</Text>
),
confirmProps: { color: "red" },
onConfirm: () => toggleAttributeInModule(attribute, false),
});
};
const updateAttributeLabel = useMutation({
...updateAttributeLabelMutation(),
onError,
onSuccess: refetchModule,
});
const onEditAttributeLabel = (attribute: AttributeSchema) => {
if (!module) return;
modals.openContextModal({
modal: "enterNameModal",
title: "Редактирование имени атрибута в модуле",
withCloseButton: true,
innerProps: {
onChange: values =>
updateAttributeLabel.mutate({
body: {
moduleId: module.id,
attributeId: attribute.id,
label: values.name,
},
}),
value: { name: attribute.label },
},
});
};
const createAttribute = useMutation({
...createAttributeMutation(),
onError,
onSuccess: refetchAttributes,
});
const onCreate = () => {
modals.openContextModal({
modal: "attributeEditorModal",
title: "Создание атрибута",
withCloseButton: true,
innerProps: {
onCreate: values =>
createAttribute.mutate({
body: {
entity: values,
},
}),
isEditing: false,
},
});
};
const updateAttribute = useMutation({
...updateAttributeMutation(),
onError,
onSuccess: () => {
refetchAttributes();
refetchModule();
},
});
const onUpdate = (attribute: AttributeSchema) => {
modals.openContextModal({
modal: "attributeEditorModal",
title: "Редактирование атрибута",
withCloseButton: true,
innerProps: {
onChange: values =>
updateAttribute.mutate({
path: {
pk: attribute.id,
},
body: {
entity: values,
},
}),
entity: attribute,
isEditing: true,
},
});
};
const deleteAttribute = useMutation({
...deleteAttributeMutation(),
onError,
onSuccess: () => {
refetchModule();
refetchAttributes();
},
});
const onDelete = (attribute: AttributeSchema) => {
modals.openConfirmModal({
title: "Удаление атрибута",
children: (
<Text>
Вы уверены, что хотите удалить атрибут "{attribute.label}"?
</Text>
),
confirmProps: { color: "red" },
onConfirm: () =>
deleteAttribute.mutate({ path: { pk: attribute.id } }),
});
};
return {
addAttributeToModule,
removeAttributeFromModule,
onEditAttributeLabel,
onCreate,
onUpdate,
onDelete,
};
};
export default useAttributesActions;

View File

@ -0,0 +1,32 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AttributeSchema } from "@/lib/client";
import {
getAttributesOptions,
getProjectsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
const useAttributesList = () => {
const queryClient = useQueryClient();
const { data, refetch } = useQuery(getAttributesOptions());
const queryKey = getProjectsQueryKey();
const setAttributes = (attributes: AttributeSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: AttributeSchema[] }) => ({
...old,
items: attributes,
})
);
};
return {
attributes: data?.items ?? [],
setAttributes,
refetch,
queryKey,
};
};
export default useAttributesList;

View File

@ -0,0 +1,51 @@
import { useMutation } from "@tanstack/react-query";
import { ModuleSchemaOutput } from "@/lib/client";
import { updateModuleMutation } from "@/lib/client/@tanstack/react-query.gen";
export type ModuleInfo = {
label: string;
description: string | null;
};
type Props = {
module?: ModuleSchemaOutput;
refetchModule: () => void;
};
export type ModulesActions = {
updateCommonInfo: (values: ModuleInfo, onSuccess?: () => void) => void;
};
const useModulesActions = ({
module,
refetchModule,
}: Props): ModulesActions => {
const updateCommonInfoMutation = useMutation(updateModuleMutation());
const updateCommonInfo = (values: ModuleInfo, onSuccess?: () => void) => {
if (!module) return;
updateCommonInfoMutation.mutate(
{
path: {
pk: module.id,
},
body: {
entity: values,
},
},
{
onSuccess: () => {
refetchModule();
onSuccess && onSuccess();
},
}
);
};
return {
updateCommonInfo,
};
};
export default useModulesActions;

View File

@ -0,0 +1,138 @@
"use client";
import { useEffect, useState } from "react";
import { Checkbox, Stack, Textarea, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import AttributeTypeSelect from "@/app/module-editor/[moduleId]/components/shared/AttributeTypeSelect/AttributeTypeSelect";
import DefaultAttributeValueInput from "@/app/module-editor/[moduleId]/components/shared/DefaultAttributeValueInput/DefaultAttributeValueInput";
import {
AttributeSchema,
CreateAttributeSchema,
UpdateAttributeSchema,
} from "@/lib/client";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
type Props = CreateEditFormProps<
AttributeSchema,
CreateAttributeSchema,
UpdateAttributeSchema
>;
const AttributeEditorModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const [isInitial, setIsInitial] = useState(true);
const [isNullableInputShown, setIsNullableInputShown] = useState(true);
const [copyTypeId, setCopyTypeId] = useState<number>();
const form = useForm<Partial<UpdateAttributeSchema>>({
initialValues: innerProps.isEditing
? innerProps.entity
: ({
label: "",
name: "",
typeId: undefined,
type: undefined,
isApplicableToGroup: false,
isShownOnDashboard: false,
isHighlightIfExpired: false,
isNullable: false,
defaultValue: null,
description: "",
} as Partial<CreateAttributeSchema>),
validate: {
label: label => !label?.trim() && "Название не заполнено",
type: type => !type && "Тип атрибута не выбран",
defaultValue: (defaultValue, values) => {
if (defaultValue === null && !values.isNullable) {
return "Укажите значение по умолчанию или разрешите пустое значение.";
}
return false;
},
},
});
useEffect(() => {
const type = form.values.type?.type;
setIsNullableInputShown(type !== "bool");
if (!isInitial) {
if (type === "bool") {
form.setFieldValue("isNullable", false);
form.setFieldValue("defaultValue", { value: false });
} else {
form.setFieldValue("defaultValue", null);
}
}
setIsInitial(false);
setCopyTypeId(form.values.type?.id);
}, [form.values.type?.id]);
return (
<BaseFormModal
{...innerProps}
closeOnSubmit
form={form}
onClose={() => context.closeContextModal(id)}>
<Stack gap={"md"}>
<TextInput
withAsterisk
label={"Название"}
{...form.getInputProps("label")}
/>
<AttributeTypeSelect
withAsterisk
disabled={innerProps.isEditing}
{...form.getInputProps("type")}
onChange={type => {
form.setFieldValue("type", type);
form.setFieldValue("typeId", type.id);
}}
/>
<Checkbox
label={"Значение синхронизировано в группе"}
{...form.getInputProps("isApplicableToGroup", {
type: "checkbox",
})}
/>
<Checkbox
label={"Значение выводится на дашборде"}
{...form.getInputProps("isShownOnDashboard", {
type: "checkbox",
})}
/>
{(form.values.type?.type === "datetime" ||
form.values.type?.type === "date") && (
<Checkbox
label={"Подсветка, если просрочено"}
{...form.getInputProps("isHighlightIfExpired", {
type: "checkbox",
})}
/>
)}
{isNullableInputShown && (
<Checkbox
label={"Может быть пустым"}
{...form.getInputProps("isNullable", {
type: "checkbox",
})}
/>
)}
{form.values.type && copyTypeId === form.values.type.id && (
<DefaultAttributeValueInput form={form} />
)}
<Textarea
label={"Описание"}
{...form.getInputProps("description")}
/>
</Stack>
</BaseFormModal>
);
};
export default AttributeEditorModal;

View File

@ -0,0 +1,39 @@
import { Suspense } from "react";
import { NextResponse } from "next/server";
import { Center, Loader } from "@mantine/core";
import PageBody from "@/app/module-editor/[moduleId]/components/shared/PageBody/PageBody";
import { ModuleEditorContextProvider } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
type Params = {
params: Promise<{
moduleId: string;
}>;
};
export default async function ModuleEditorPage({ params }: Params) {
const { moduleId } = await params;
const id = Number(moduleId);
if (isNaN(id)) {
return NextResponse.json(
{ error: "Некорректный ID модуля. ID должен быть числом." },
{ status: 400 }
);
}
return (
<Suspense
fallback={
<Center h="50vh">
<Loader size="lg" />
</Center>
}>
<PageContainer>
<ModuleEditorContextProvider moduleId={id}>
<PageBody />
</ModuleEditorContextProvider>
</PageContainer>
</Suspense>
);
}

View File

@ -0,0 +1,38 @@
"use client";
import { FC } from "react";
import { Center, Text } from "@mantine/core";
import useAttributesInnerTableColumns from "@/app/modules/hooks/useAttributesInnerTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { ModuleAttributeSchema } from "@/lib/client";
type Props = {
attributes: ModuleAttributeSchema[];
moduleId: number;
};
const InnerAttributesTable: FC<Props> = ({ attributes, moduleId }) => {
const innerColumns = useAttributesInnerTableColumns();
if (attributes.length === 0) {
return (
<Center my={"md"}>
<Text>В модуле нет атрибутов</Text>
</Center>
);
}
return (
<BaseTable
key={moduleId}
withTableBorder
columns={innerColumns}
records={attributes}
verticalSpacing={"md"}
groups={undefined}
styles={{ table: { width: "100%" } }}
/>
);
};
export default InnerAttributesTable;

View File

@ -0,0 +1,24 @@
"use client";
import { FC } from "react";
import { Group } from "@mantine/core";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useIsMobile from "@/hooks/utils/useIsMobile";
const ModulesHeader: FC = () => {
const isMobile = useIsMobile();
return (
<Group
wrap={"nowrap"}
justify={"space-between"}
mt={isMobile ? "xs" : ""}
mx={isMobile ? "xs" : ""}>
<InlineButton w={isMobile ? "100%" : ""}>
Создать модуль
</InlineButton>
</Group>
);
};
export default ModulesHeader;

View File

@ -0,0 +1,46 @@
"use client";
import { FC, useState } from "react";
import { useModulesContext } from "@/app/modules/contexts/ModulesContext";
import useModulesTableColumns from "@/app/modules/hooks/useModulesTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
import InnerAttributesTable from "@/app/modules/components/InnerAttributesTable";
const ModulesTable: FC = () => {
const isMobile = useIsMobile();
const { modules } = useModulesContext();
const [expandedModuleIds, setExpandedModuleIds] = useState<number[]>([]);
const outerColumns = useModulesTableColumns({
expandedModuleIds,
setExpandedModuleIds,
});
return (
<BaseTable
columns={outerColumns}
groups={undefined}
records={modules}
withTableBorder
verticalSpacing={"md"}
rowExpansion={{
allowMultiple: true,
expanded: {
recordIds: expandedModuleIds,
onRecordIdsChange: setExpandedModuleIds,
},
content: ({ record }) => (
<InnerAttributesTable
attributes={record.attributes}
moduleId={record.id}
/>
),
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default ModulesTable;

View File

@ -0,0 +1,26 @@
"use client";
import ModulesTable from "@/app/modules/components/ModulesTable";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import ModulesHeader from "./ModulesHeader";
const PageBody = () => (
<PageBlock
style={{ flex: 1, minHeight: 0 }}
fullScreenMobile>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
gap: "var(--mantine-spacing-md)",
}}>
<ModulesHeader />
<div style={{ flex: 1, overflow: "auto" }}>
<ModulesTable />
</div>
</div>
</PageBlock>
);
export default PageBody;

View File

@ -0,0 +1,22 @@
"use client";
import useModulesWithAttrsList from "@/app/modules/hooks/useModulesWithAttrsList";
import { ModuleWithAttributesSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
type ModulesContextState = {
modules: ModuleWithAttributesSchema[];
refetchModules: () => void;
};
const useModulesContextState = (): ModulesContextState => {
const { modules, refetch } = useModulesWithAttrsList();
return {
modules,
refetchModules: refetch,
};
};
export const [ModulesContextProvider, useModulesContext] =
makeContext<ModulesContextState>(useModulesContextState, "Modules");

View File

@ -0,0 +1,82 @@
"use client";
import { useMemo } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Box } from "@mantine/core";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { ModuleAttributeSchema } from "@/lib/client";
import {
utcDateTimeToLocalString,
utcDateToLocalString,
} from "@/utils/datetime";
const useAttributesInnerTableColumns = () => {
const isMobile = useIsMobile();
const renderCheck = (value: boolean) => (value ? <IconCheck /> : <IconX />);
return useMemo(
() =>
[
{
title: "Название атрибута",
accessor: "label",
},
{
title: "Тип",
accessor: "type.name",
},
{
title: "Значение по умолчанию",
accessor: "defaultValue",
render: attr => {
if (!attr.defaultValue) return <>-</>;
const value = attr.defaultValue.value;
if (value === null) return <>-</>;
const type = attr.type.type;
if (type === "datetime") {
return utcDateTimeToLocalString(value as string);
}
if (type === "date") {
return utcDateToLocalString(value as string);
}
if (type === "bool") {
return value ? <IconCheck /> : <IconX />;
}
return <>{value}</>;
},
},
{
title: "Синхронизировано в группе",
accessor: "isApplicableToGroup",
render: attr => renderCheck(attr.isApplicableToGroup),
},
{
title: "Вывод на дашборде",
accessor: "isShownOnDashboard",
render: attr => renderCheck(attr.isShownOnDashboard),
},
{
title: "Подсветка, если просрочен",
accessor: "isHighlightIfExpired",
render: attr => renderCheck(attr.isHighlightIfExpired),
},
{
title: "Может быть пустым",
accessor: "isNullable",
render: attr => renderCheck(attr.isNullable),
},
{
title: "Описаниие",
accessor: "description",
render: attr => <Box>{attr.description}</Box>,
},
] as DataTableColumn<ModuleAttributeSchema>[],
[isMobile]
);
};
export default useAttributesInnerTableColumns;

View File

@ -0,0 +1,57 @@
import React from "react";
import { redirect } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { HttpValidationError, ModuleSchemaOutput } from "@/lib/client";
import { deleteModuleMutation } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
export type ModulesActions = {
onUpdate: (module: ModuleSchemaOutput) => void;
onDelete: (module: ModuleSchemaOutput) => void;
};
type Props = {
refetchModules: () => void;
};
const useModulesActions = ({ refetchModules }: Props): ModulesActions => {
const onUpdate = (module: ModuleSchemaOutput) => {
redirect(`/module-editor/${module.id}`);
};
const onError = (error: AxiosError<HttpValidationError>, _: any) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
};
const deleteMutation = useMutation({
...deleteModuleMutation(),
onError,
onSuccess: refetchModules,
});
const onDelete = (module: ModuleSchemaOutput) => {
modals.openConfirmModal({
title: "Удаление услуги из сделки",
children: (
<Text>
Вы уверены, что хотите удалить модуль "{module.label}"?
</Text>
),
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate({ path: { pk: module.id } }),
});
};
return {
onUpdate,
onDelete,
};
};
export default useModulesActions;

View File

@ -0,0 +1,112 @@
import { useMemo } from "react";
import {
IconChevronDown,
IconChevronsDown,
IconChevronsRight,
IconChevronsUp,
IconChevronUp,
} from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Box, Group, Text, Tooltip } from "@mantine/core";
import useModulesActions from "@/app/modules/hooks/useModulesTableActions";
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { ModuleWithAttributesSchema } from "@/lib/client";
import { useModulesContext } from "../contexts/ModulesContext";
type Props = {
expandedModuleIds: number[];
setExpandedModuleIds: (ids: number[]) => void;
};
const useModulesTableColumns = ({
expandedModuleIds,
setExpandedModuleIds,
}: Props) => {
const isMobile = useIsMobile();
const { modules, refetchModules } = useModulesContext();
const { onUpdate, onDelete } = useModulesActions({ refetchModules });
const onExpandAllClick = () => {
if (expandedModuleIds.length !== modules.length) {
setExpandedModuleIds(modules.map(c => c.id));
return;
}
setExpandedModuleIds([]);
};
const getExpandAllIcon = () => {
if (expandedModuleIds.length === modules.length)
return <IconChevronsUp />;
if (expandedModuleIds.length === 0) return <IconChevronsDown />;
return <IconChevronsRight />;
};
return useMemo(
() =>
[
{
accessor: "",
title: (
<Group>
<Box
style={{ cursor: "pointer" }}
onClick={onExpandAllClick}>
{getExpandAllIcon()}
</Box>
Модуль
</Group>
),
render: ({ id, label }) => (
<Group
key={id}
wrap={"nowrap"}>
{expandedModuleIds.includes(id) ? (
<IconChevronUp />
) : (
<IconChevronDown />
)}
<Text>{label}</Text>
</Group>
),
},
{
title: "Описание",
accessor: "description",
},
{
title: "Зависит от модулей",
accessor: "dependsOn",
render: module => (
<Text>
{module.dependsOn?.map(m => m.label).join(", ")}
</Text>
),
},
{
accessor: "actions",
title: isMobile ? "" : "Действия",
sortable: false,
textAlign: "center",
width: "0%",
render: module => (
<Tooltip
label={
module.isBuiltIn
? "Нельзя изменять встроенные модули"
: null
}>
<UpdateDeleteTableActions
onDelete={() => onDelete(module)}
onChange={() => onUpdate(module)}
disabled={module.isBuiltIn}
/>
</Tooltip>
),
},
] as DataTableColumn<ModuleWithAttributesSchema>[],
[expandedModuleIds, modules, isMobile]
);
};
export default useModulesTableColumns;

View File

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getModulesWithAttributesOptions } from "@/lib/client/@tanstack/react-query.gen";
const useModulesWithAttrsList = () => {
const { data, refetch } = useQuery(getModulesWithAttributesOptions());
return {
modules: data?.items ?? [],
refetch,
};
};
export default useModulesWithAttrsList;

22
src/app/modules/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Suspense } from "react";
import { Center, Loader } from "@mantine/core";
import PageBody from "@/app/modules/components/PageBody";
import { ModulesContextProvider } from "@/app/modules/contexts/ModulesContext";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
export default async function ModulesPage() {
return (
<Suspense
fallback={
<Center h="50vh">
<Loader size="lg" />
</Center>
}>
<PageContainer>
<ModulesContextProvider>
<PageBody />
</ModulesContextProvider>
</PageContainer>
</Suspense>
);
}