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

@ -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;