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";
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 Action from "@/app/actions/components/Action/Action";
import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
@ -23,6 +23,11 @@ const PageBody = () => {
);
const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([
{
icon: IconList,
label: "Атрибуты",
href: "/attributes",
},
{
icon: IconTag,
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 { IconArrowRight, IconEdit, IconTrash } 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;
onUpdate: (attribute: AttributeSchema) => void;
onDelete: (attribute: AttributeSchema) => void;
addAttributeToModule?: (attribute: AttributeSchema) => void;
};
const AttributeTableActions: FC<Props> = ({ attribute }) => {
const { attributeActions } = useModuleEditorContext();
const AttributeTableActions: FC<Props> = ({
attribute,
onUpdate,
onDelete,
addAttributeToModule,
}) => {
return (
<Center>
<Flex gap={"xs"}>
<ActionIconWithTip
onClick={() => attributeActions.onUpdate(attribute)}
onClick={() => onUpdate(attribute)}
disabled={attribute.isBuiltIn}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
<ActionIconWithTip
onClick={() => attributeActions.onDelete(attribute)}
onClick={() => onDelete(attribute)}
disabled={attribute.isBuiltIn}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
{addAttributeToModule && (
<ActionIconWithTip
onClick={() =>
attributeActions.addAttributeToModule(attribute)
}
onClick={() => addAttributeToModule(attribute)}
tipLabel={"Добавить в модуль"}>
<IconArrowRight />
</ActionIconWithTip>
)}
</Flex>
</Center>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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