feat: attributes page

This commit is contained in:
2025-11-02 16:07:49 +04:00
parent 8020561da6
commit 03be3903cb
16 changed files with 348 additions and 48 deletions

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { RefObject, useMemo, useRef } from "react"; import { RefObject, useMemo, useRef } from "react";
import { IconTag } from "@tabler/icons-react"; import { IconList, IconTag } from "@tabler/icons-react";
import { SimpleGrid, Stack } from "@mantine/core"; import { SimpleGrid, Stack } from "@mantine/core";
import Action from "@/app/actions/components/Action/Action"; import Action from "@/app/actions/components/Action/Action";
import mobileButtonsData from "@/app/actions/data/mobileButtonsData"; import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
@ -23,6 +23,11 @@ const PageBody = () => {
); );
const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([ const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([
{
icon: IconList,
label: "Атрибуты",
href: "/attributes",
},
{ {
icon: IconTag, icon: IconTag,
label: "Теги", label: "Теги",

View File

@ -0,0 +1,37 @@
"use client";
import { FC } from "react";
import { Group, TextInput } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useIsMobile from "@/hooks/utils/useIsMobile";
const AttributesHeader: FC = () => {
const {
attributesActions: { onCreate },
search,
setSearch,
} = useAttributesContext();
const isMobile = useIsMobile();
return (
<Group
wrap={"nowrap"}
mt={isMobile ? "xs" : ""}
mx={isMobile ? "xs" : ""}>
<InlineButton
onClick={onCreate}
w={isMobile ? "100%" : "auto"}>
Создать атрибут
</InlineButton>
<TextInput
value={search}
onChange={e => setSearch(e.currentTarget.value)}
w={isMobile ? "100%" : "auto"}
placeholder={"Поиск..."}
/>
</Group>
);
};
export default AttributesHeader;

View File

@ -0,0 +1,40 @@
"use client";
import { FC } from "react";
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import useAttributesTableColumns from "@/app/attributes/hooks/useAttributesTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const AttributesTable: FC = () => {
const isMobile = useIsMobile();
const { attributes } = useAttributesContext();
const columns = useAttributesTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={attributes}
verticalSpacing={"md"}
emptyState={
<Group mt={attributes.length === 0 ? "xl" : 0}>
<Text>Нет атрибутов</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
},
header: { zIndex: 1 },
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default AttributesTable;

View File

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

View File

@ -0,0 +1,40 @@
"use client";
import { Dispatch, SetStateAction } from "react";
import useFilteredAttributes from "@/app/attributes/hooks/useFilteredAttributes";
import useAttributesActions, {
AttributesActions,
} from "@/app/module-editor/[moduleId]/hooks/useAttributesActions";
import useAttributesList from "@/app/module-editor/[moduleId]/hooks/useAttributesList";
import { AttributeSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
type AttributesContextState = {
attributes: AttributeSchema[];
refetchAttributes: () => void;
attributesActions: AttributesActions;
search: string;
setSearch: Dispatch<SetStateAction<string>>;
};
const useAttributesContextState = (): AttributesContextState => {
const { attributes, refetch } = useAttributesList();
const attributesActions = useAttributesActions({
refetchAttributes: refetch,
});
const { search, setSearch, filteredAttributes } = useFilteredAttributes({
attributes,
});
return {
attributes: filteredAttributes,
refetchAttributes: refetch,
attributesActions,
search,
setSearch,
};
};
export const [AttributesContextProvider, useAttributesContext] =
makeContext<AttributesContextState>(useAttributesContextState, "Attribute");

View File

@ -0,0 +1,72 @@
"use client";
import { useMemo } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Box, Center } from "@mantine/core";
import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions";
import AttributeDefaultValue from "@/components/ui/AttributeDefaultValue/AttributeDefaultValue";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttributeSchema } from "@/lib/client";
import { useAttributesContext } from "../contexts/AttributesContext";
const useAttributesTableColumns = () => {
const isMobile = useIsMobile();
const { attributesActions } = useAttributesContext();
const renderCheck = (value: boolean) => (value ? <IconCheck /> : <IconX />);
return useMemo(
() =>
[
{
title: "Название атрибута",
accessor: "label",
},
{
title: "Тип",
accessor: "type.name",
render: attr =>
attr.type.type === "select"
? `Выбор "${attr.label}"`
: attr.type.name,
},
{
title: "Значение по умолчанию",
accessor: "defaultValue",
render: attr => <AttributeDefaultValue attribute={attr} />,
},
{
title: isMobile
? "Синх. в группе"
: "Синхронизировано в группе",
accessor: "isApplicableToGroup",
render: attr => renderCheck(attr.isApplicableToGroup),
},
{
title: "Может быть пустым",
accessor: "isNullable",
render: attr => renderCheck(attr.isNullable),
},
{
title: "Описаниие",
accessor: "description",
render: attr => <Box>{attr.description}</Box>,
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: attribute => (
<AttributeTableActions
attribute={attribute}
onUpdate={attributesActions.onUpdate}
onDelete={attributesActions.onDelete}
/>
),
},
] as DataTableColumn<AttributeSchema>[],
[isMobile]
);
};
export default useAttributesTableColumns;

View File

@ -0,0 +1,29 @@
import { useMemo, useState } from "react";
import { AttributeSchema } from "@/lib/client";
type Props = {
attributes: AttributeSchema[];
};
const useFilteredAttributes = ({ attributes }: Props) => {
const [search, setSearch] = useState<string>("");
const filteredAttributes = useMemo(
() =>
attributes.filter(
attr =>
attr.type.name.includes(search) ||
attr.label.includes(search) ||
attr.description.includes(search)
),
[attributes, search]
);
return {
search,
setSearch,
filteredAttributes,
};
};
export default useFilteredAttributes;

View File

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

View File

@ -1,39 +1,44 @@
import { FC } from "react"; import { FC } from "react";
import { IconArrowRight, IconEdit, IconTrash } from "@tabler/icons-react"; import { IconArrowRight, IconEdit, IconTrash } from "@tabler/icons-react";
import { Center, Flex } from "@mantine/core"; import { Center, Flex } from "@mantine/core";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip"; import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { AttributeSchema } from "@/lib/client"; import { AttributeSchema } from "@/lib/client";
type Props = { type Props = {
attribute: AttributeSchema; attribute: AttributeSchema;
onUpdate: (attribute: AttributeSchema) => void;
onDelete: (attribute: AttributeSchema) => void;
addAttributeToModule?: (attribute: AttributeSchema) => void;
}; };
const AttributeTableActions: FC<Props> = ({ attribute }) => { const AttributeTableActions: FC<Props> = ({
const { attributeActions } = useModuleEditorContext(); attribute,
onUpdate,
onDelete,
addAttributeToModule,
}) => {
return ( return (
<Center> <Center>
<Flex gap={"xs"}> <Flex gap={"xs"}>
<ActionIconWithTip <ActionIconWithTip
onClick={() => attributeActions.onUpdate(attribute)} onClick={() => onUpdate(attribute)}
disabled={attribute.isBuiltIn} disabled={attribute.isBuiltIn}
tipLabel={"Редактировать"}> tipLabel={"Редактировать"}>
<IconEdit /> <IconEdit />
</ActionIconWithTip> </ActionIconWithTip>
<ActionIconWithTip <ActionIconWithTip
onClick={() => attributeActions.onDelete(attribute)} onClick={() => onDelete(attribute)}
disabled={attribute.isBuiltIn} disabled={attribute.isBuiltIn}
tipLabel={"Удалить"}> tipLabel={"Удалить"}>
<IconTrash /> <IconTrash />
</ActionIconWithTip> </ActionIconWithTip>
<ActionIconWithTip {addAttributeToModule && (
onClick={() => <ActionIconWithTip
attributeActions.addAttributeToModule(attribute) onClick={() => addAttributeToModule(attribute)}
} tipLabel={"Добавить в модуль"}>
tipLabel={"Добавить в модуль"}> <IconArrowRight />
<IconArrowRight /> </ActionIconWithTip>
</ActionIconWithTip> )}
</Flex> </Flex>
</Center> </Center>
); );

View File

@ -3,12 +3,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable"; import { DataTableColumn } from "mantine-datatable";
import { Center } from "@mantine/core"; import { Center } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions"; import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttributeSchema } from "@/lib/client"; import { AttributeSchema } from "@/lib/client";
const useAttributesTableColumns = () => { const useAttributesTableColumns = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { attributesActions } = useAttributesContext();
return useMemo( return useMemo(
() => () =>
@ -30,7 +32,10 @@ const useAttributesTableColumns = () => {
title: <Center>Действия</Center>, title: <Center>Действия</Center>,
width: "0%", width: "0%",
render: attribute => ( render: attribute => (
<AttributeTableActions attribute={attribute} /> <AttributeTableActions
attribute={attribute}
{...attributesActions}
/>
), ),
}, },
] as DataTableColumn<AttributeSchema>[], ] as DataTableColumn<AttributeSchema>[],

View File

@ -1,5 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import { IconEdit, IconX } from "@tabler/icons-react"; import { IconEditCircle, IconX } from "@tabler/icons-react";
import { Card, Group, Stack, Text, Title } from "@mantine/core"; import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext"; import { useModuleEditorContext } from "@/app/module-editor/[moduleId]/contexts/ModuleEditorContext";
import styles from "@/app/module-editor/[moduleId]/ModuleEditor.module.css"; import styles from "@/app/module-editor/[moduleId]/ModuleEditor.module.css";
@ -55,7 +55,7 @@ const ModuleAttribute: FC<Props> = ({ attribute }) => {
"Редактировать название (только для данного модуля)" "Редактировать название (только для данного модуля)"
} }
onClick={() => onEditAttributeLabel(attribute)}> onClick={() => onEditAttributeLabel(attribute)}>
<IconEdit /> <IconEditCircle />
</ActionIconWithTip> </ActionIconWithTip>
<ActionIconWithTip <ActionIconWithTip
tipLabel={"Удалить из модуля"} tipLabel={"Удалить из модуля"}

View File

@ -15,7 +15,7 @@ export type AttributesActions = {
type Props = { type Props = {
module?: ModuleSchemaOutput; module?: ModuleSchemaOutput;
refetchModule: () => void; refetchModule?: () => void;
refetchAttributes: () => void; refetchAttributes: () => void;
}; };
@ -40,7 +40,7 @@ const useAttributesActions = (props: Props): AttributesActions => {
}; };
const onEditAttributeLabel = (attribute: AttributeSchema) => { const onEditAttributeLabel = (attribute: AttributeSchema) => {
if (!module) return; if (!props.module) return;
modals.openContextModal({ modals.openContextModal({
modal: "enterNameModal", modal: "enterNameModal",

View File

@ -19,7 +19,7 @@ import { notifications } from "@/lib/notifications";
type Props = { type Props = {
module?: ModuleSchemaOutput; module?: ModuleSchemaOutput;
refetchModule: () => void; refetchModule?: () => void;
refetchAttributes: () => void; refetchAttributes: () => void;
}; };
@ -95,7 +95,7 @@ const useAttributesCrud = ({
{ {
onSuccess: ({ message }) => { onSuccess: ({ message }) => {
notifications.success({ message }); notifications.success({ message });
refetchModule(); refetchModule && refetchModule();
refetchAttributes(); refetchAttributes();
removeGetDealModuleAttrQuery(); removeGetDealModuleAttrQuery();
}, },
@ -139,8 +139,8 @@ const useAttributesCrud = ({
...updateAttributeMutation(), ...updateAttributeMutation(),
onError, onError,
onSuccess: () => { onSuccess: () => {
refetchModule && refetchModule();
refetchAttributes(); refetchAttributes();
refetchModule();
}, },
}); });
@ -159,7 +159,7 @@ const useAttributesCrud = ({
...deleteAttributeMutation(), ...deleteAttributeMutation(),
onError, onError,
onSuccess: () => { onSuccess: () => {
refetchModule(); refetchModule && refetchModule();
refetchAttributes(); refetchAttributes();
}, },
}); });

View File

@ -4,13 +4,9 @@ import { useMemo } from "react";
import { IconCheck, IconX } from "@tabler/icons-react"; import { IconCheck, IconX } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable"; import { DataTableColumn } from "mantine-datatable";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import AttributeDefaultValue from "@/components/ui/AttributeDefaultValue/AttributeDefaultValue";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { ModuleAttributeSchema } from "@/lib/client"; import { ModuleAttributeSchema } from "@/lib/client";
import {
naiveDateTimeStringToUtc,
utcDateTimeToLocalString,
utcDateToLocalString,
} from "@/utils/datetime";
const useAttributesInnerTableColumns = () => { const useAttributesInnerTableColumns = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -35,26 +31,7 @@ const useAttributesInnerTableColumns = () => {
{ {
title: "Значение по умолчанию", title: "Значение по умолчанию",
accessor: "defaultValue", accessor: "defaultValue",
render: attr => { render: attr => <AttributeDefaultValue attribute={attr} />,
if (!attr.defaultValue) return <>-</>;
const value = attr.defaultValue;
if (value === null) return <>-</>;
const type = attr.type.type;
if (type === "datetime") {
return utcDateTimeToLocalString(
naiveDateTimeStringToUtc(value as string)
);
}
if (type === "date") {
return utcDateToLocalString(value as string);
}
if (type === "bool") {
return value ? <IconCheck /> : <IconX />;
}
return <>{value}</>;
},
}, },
{ {
title: "Синхронизировано в группе", title: "Синхронизировано в группе",

View File

@ -4,6 +4,7 @@ import {
IconColumns, IconColumns,
IconFileBarcode, IconFileBarcode,
IconLayoutKanban, IconLayoutKanban,
IconList,
IconUsers, IconUsers,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { ModuleNames } from "@/modules/modules"; import { ModuleNames } from "@/modules/modules";
@ -22,6 +23,12 @@ const linksData: LinkData[] = [
href: "/modules", href: "/modules",
moduleName: undefined, moduleName: undefined,
}, },
{
icon: IconList,
label: "Атрибуты",
href: "/attributes",
moduleName: undefined,
},
{ {
icon: IconUsers, icon: IconUsers,
label: "Клиенты", label: "Клиенты",

View File

@ -0,0 +1,35 @@
import { FC } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { AttributeSchema } from "@/lib/client";
import {
naiveDateTimeStringToUtc,
utcDateTimeToLocalString,
utcDateToLocalString,
} from "@/utils/datetime";
type Props = {
attribute: AttributeSchema;
};
const AttributeDefaultValue: FC<Props> = ({ attribute }) => {
if (!attribute.defaultValue) return <>-</>;
const value = attribute.defaultValue;
if (value === null) return <>-</>;
const type = attribute.type.type;
if (type === "datetime") {
return utcDateTimeToLocalString(
naiveDateTimeStringToUtc(value as string)
);
}
if (type === "date") {
return utcDateToLocalString(value as string);
}
if (type === "bool") {
return value ? <IconCheck /> : <IconX />;
}
return <>{value}</>;
};
export default AttributeDefaultValue;