feat: attribute selects and options editors

This commit is contained in:
2025-11-04 12:19:44 +04:00
parent 311210394f
commit 33dd1e1c0f
31 changed files with 1833 additions and 112 deletions

View File

@ -0,0 +1,27 @@
import { FC } from "react";
import AttributePageView from "@/app/attributes/types/view";
import BaseSegmentedControl, {
BaseSegmentedControlProps,
} from "@/components/ui/BaseSegmentedControl/BaseSegmentedControl";
type Props = Omit<BaseSegmentedControlProps<AttributePageView>, "data">;
const data = [
{
label: "Аттрибуты",
value: AttributePageView.ATTRIBUTES,
},
{
label: "Справочники",
value: AttributePageView.SELECTS,
},
];
const AttrViewSegmentedControl: FC<Props> = props => (
<BaseSegmentedControl
data={data}
{...props}
/>
);
export default AttrViewSegmentedControl;

View File

@ -1,34 +1,79 @@
"use client";
import { FC } from "react";
import { Dispatch, FC, SetStateAction, useMemo } from "react";
import { Group, TextInput } from "@mantine/core";
import AttrViewSegmentedControl from "@/app/attributes/components/AttrViewSegmentedControl";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import AttributePageView from "@/app/attributes/types/view";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useIsMobile from "@/hooks/utils/useIsMobile";
const AttributesHeader: FC = () => {
type Props = {
view: AttributePageView;
setView: Dispatch<SetStateAction<AttributePageView>>;
};
const AttributesHeader: FC<Props> = ({ view, setView }) => {
const {
attributesActions: { onCreate },
search,
setSearch,
attributesActions,
selectsActions,
attrSearch,
setAttrSearch,
selectSearch,
setSelectSearch,
} = useAttributesContext();
const isMobile = useIsMobile();
const attributeActions = useMemo(
() => (
<Group wrap={"nowrap"}>
<InlineButton
onClick={attributesActions.onCreate}
w={isMobile ? "100%" : "auto"}>
Создать атрибут
</InlineButton>
<TextInput
value={attrSearch}
onChange={e => setAttrSearch(e.currentTarget.value)}
w={isMobile ? "100%" : "auto"}
placeholder={"Поиск..."}
/>
</Group>
),
[isMobile, attrSearch]
);
const selectActions = useMemo(
() => (
<Group wrap={"nowrap"}>
<InlineButton
onClick={selectsActions.onCreate}
w={isMobile ? "100%" : "auto"}>
Создать справочник
</InlineButton>
<TextInput
value={selectSearch}
onChange={e => setSelectSearch(e.currentTarget.value)}
w={isMobile ? "100%" : "auto"}
placeholder={"Поиск..."}
/>
</Group>
),
[isMobile, selectSearch]
);
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={"Поиск..."}
mx={isMobile ? "xs" : ""}
justify={"space-between"}>
{view === AttributePageView.ATTRIBUTES
? attributeActions
: selectActions}
<AttrViewSegmentedControl
value={view}
onChange={setView}
/>
</Group>
);

View File

@ -1,26 +1,42 @@
"use client";
import { useState } from "react";
import AttributesHeader from "@/app/attributes/components/AttributesHeader";
import AttributesTable from "@/app/attributes/components/AttributesTable";
import SelectsTable from "@/app/attributes/components/SelectsTable";
import AttributePageView from "@/app/attributes/types/view";
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 />
const PageBody = () => {
const [view, setView] = useState<AttributePageView>(
AttributePageView.ATTRIBUTES
);
return (
<PageBlock
style={{ flex: 1, minHeight: 0 }}
fullScreenMobile>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
gap: "var(--mantine-spacing-md)",
}}>
<AttributesHeader
view={view}
setView={setView}
/>
<div style={{ flex: 1, overflow: "auto" }}>
{view === AttributePageView.ATTRIBUTES ? (
<AttributesTable />
) : (
<SelectsTable />
)}
</div>
</div>
</div>
</PageBlock>
);
</PageBlock>
);
};
export default PageBody;

View File

@ -0,0 +1,36 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import useSelectsTableColumns from "@/app/attributes/hooks/useSelectsTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const SelectsTable = () => {
const { selects } = useAttributesContext();
const isMobile = useIsMobile();
const columns = useSelectsTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={selects}
verticalSpacing={"md"}
emptyState={
<Group mt={selects.length === 0 ? "xl" : 0}>
<Text>Нет справочников</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
},
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default SelectsTable;

View File

@ -2,37 +2,59 @@
import { Dispatch, SetStateAction } from "react";
import useFilteredAttributes from "@/app/attributes/hooks/useFilteredAttributes";
import useSelectsActions, {
SelectsActions,
} from "@/app/attributes/hooks/useSelectsActions";
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 useAttrSelectsList from "@/hooks/lists/useAttrSelectsList";
import { AttributeSchema, AttrSelectSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
import useFilteredSelects from "@/app/attributes/hooks/useFilteredSelects";
type AttributesContextState = {
attributes: AttributeSchema[];
refetchAttributes: () => void;
attributesActions: AttributesActions;
search: string;
setSearch: Dispatch<SetStateAction<string>>;
attrSearch: string;
setAttrSearch: Dispatch<SetStateAction<string>>;
selects: AttrSelectSchema[];
selectsActions: SelectsActions;
selectSearch: string;
setSelectSearch: Dispatch<SetStateAction<string>>;
};
const useAttributesContextState = (): AttributesContextState => {
const { attributes, refetch } = useAttributesList();
const { attributes, refetch: refetchAttributes } = useAttributesList();
const attributesActions = useAttributesActions({
refetchAttributes: refetch,
refetchAttributes,
});
const { search, setSearch, filteredAttributes } = useFilteredAttributes({
attributes,
});
const {
search: attrSearch,
setSearch: setAttrSearch,
filteredAttributes,
} = useFilteredAttributes({ attributes });
const { selects, queryKey } = useAttrSelectsList();
const selectsActions = useSelectsActions({ queryKey });
const {
search: selectSearch,
setSearch: setSelectSearch,
filteredSelects,
} = useFilteredSelects({ selects });
return {
attributes: filteredAttributes,
refetchAttributes: refetch,
attributesActions,
search,
setSearch,
attrSearch,
setAttrSearch,
selects: filteredSelects,
selectsActions,
selectSearch,
setSelectSearch,
};
};

View File

@ -0,0 +1,23 @@
import { useMemo, useState } from "react";
import { AttrSelectSchema } from "@/lib/client";
type Props = {
selects: AttrSelectSchema[];
};
const useFilteredSelects = ({ selects }: Props) => {
const [search, setSearch] = useState<string>("");
const filteredSelects = useMemo(
() => selects.filter(s => s.name.includes(search)),
[selects, search]
);
return {
search,
setSearch,
filteredSelects,
};
};
export default useFilteredSelects;

View File

@ -0,0 +1,49 @@
import { modals } from "@mantine/modals";
import { useAttrSelectsCrud } from "@/hooks/cruds/useSelectsCrud";
import { AttrSelectSchema } from "@/lib/client";
type Props = {
queryKey: any[];
};
export type SelectsActions = {
onCreate: () => void;
onUpdate: (select: AttrSelectSchema) => void;
onDelete: (select: AttrSelectSchema) => void;
};
const useSelectsActions = (props: Props): SelectsActions => {
const attrSelectsCrud = useAttrSelectsCrud(props);
const onCreate = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Создание справочника",
innerProps: {
onChange: values => attrSelectsCrud.onCreate(values),
},
});
};
const onUpdate = (select: AttrSelectSchema) => {
modals.openContextModal({
modal: "attrSelectEditorModal",
title: "Редактирование справочника",
innerProps: {
onSelectChange: (values, onSuccess) =>
attrSelectsCrud.onUpdate(select.id, values, onSuccess),
select,
},
});
};
const onDelete = attrSelectsCrud.onDelete;
return {
onCreate,
onUpdate,
onDelete,
};
};
export default useSelectsActions;

View File

@ -0,0 +1,38 @@
"use client";
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { Center } from "@mantine/core";
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrSelectSchema } from "@/lib/client";
import { useAttributesContext } from "../contexts/AttributesContext";
const useSelectsTableColumns = () => {
const isMobile = useIsMobile();
const { selectsActions } = useAttributesContext();
return useMemo(
() =>
[
{
title: "Название справочника",
accessor: "name",
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: select => (
<UpdateDeleteTableActions
onDelete={() => selectsActions.onDelete(select)}
onChange={() => selectsActions.onUpdate(select)}
/>
),
},
] as DataTableColumn<AttrSelectSchema>[],
[isMobile]
);
};
export default useSelectsTableColumns;

View File

@ -0,0 +1,24 @@
"use client";
import { ContextModalProps } from "@mantine/modals";
import EditorBody from "@/app/attributes/modals/AttrSelectEditorModal/components/EditorBody";
import { SelectEditorContextProvider } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import { AttrSelectSchema, UpdateAttrSelectSchema } from "@/lib/client";
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const AttrSelectEditorModal = ({ innerProps }: ContextModalProps<Props>) => {
return (
<SelectEditorContextProvider {...innerProps}>
<EditorBody />
</SelectEditorContextProvider>
);
};
export default AttrSelectEditorModal;

View File

@ -0,0 +1,38 @@
import { Button, Flex, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import { UpdateAttrSelectSchema } from "@/lib/client";
const CommonInfoEditor = () => {
const { select, onSelectChange } = useSelectEditorContext();
const form = useForm<UpdateAttrSelectSchema>({
initialValues: select || {
name: "",
},
validate: {
name: name => !name && "Введите название",
},
});
return (
<form onSubmit={form.onSubmit(values => onSelectChange(values))}>
<Flex
gap={"xs"}
direction={"column"}>
<TextInput
label={"Название справочника"}
{...form.getInputProps("name")}
data-autofocus
/>
<Button
variant={"default"}
type={"submit"}>
Сохранить
</Button>
</Flex>
</form>
);
};
export default CommonInfoEditor;

View File

@ -0,0 +1,37 @@
import { IconCheck } from "@tabler/icons-react";
import { Flex, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
const CreateOptionButton = () => {
const {
optionsActions: {
isCreatingOption,
createOptionForm,
onStartCreating,
onFinishCreating,
},
} = useSelectEditorContext();
if (!isCreatingOption) {
return (
<InlineButton onClick={onStartCreating}>
Добавить опцию
</InlineButton>
);
}
return (
<Flex gap={"xs"}>
<TextInput {...createOptionForm.getInputProps("name")} flex={1} />
<ActionIconWithTip
tipLabel={"Сохранить"}
onClick={onFinishCreating}>
<IconCheck />
</ActionIconWithTip>
</Flex>
);
};
export default CreateOptionButton;

View File

@ -0,0 +1,20 @@
import { Divider, Stack } from "@mantine/core";
import CommonInfoEditor from "@/app/attributes/modals/AttrSelectEditorModal/components/CommonInfoEditor";
import CreateOptionButton from "@/app/attributes/modals/AttrSelectEditorModal/components/CreateOptionButton";
import OptionsTable from "@/app/attributes/modals/AttrSelectEditorModal/components/OptionsTable";
const EditorBody = () => {
return (
<Stack gap={"xs"}>
<CommonInfoEditor />
<Divider
label={"Опции"}
my={"xs"}
/>
<CreateOptionButton />
<OptionsTable />
</Stack>
);
};
export default EditorBody;

View File

@ -0,0 +1,37 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import useOptionsTableColumns from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useOptionsTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const OptionsTable = () => {
const { options } = useSelectEditorContext();
const isMobile = useIsMobile();
const columns = useOptionsTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={options}
verticalSpacing={"md"}
emptyState={
<Group mt={options.length === 0 ? "xl" : 0}>
<Text>Нет опций</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
},
header: { zIndex: 1 },
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default OptionsTable;

View File

@ -0,0 +1,56 @@
"use client";
import useAttrOptionsList from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useAttrOptionsList";
import {
AttrOptionSchema,
AttrSelectSchema,
UpdateAttrSelectSchema,
} from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
import { notifications } from "@/lib/notifications";
import useOptionsActions, { OptionsActions } from "../hooks/useOptionsActions";
type SelectEditorContextState = {
select: AttrSelectSchema;
onSelectChange: (values: UpdateAttrSelectSchema) => void;
options: AttrOptionSchema[];
optionsActions: OptionsActions;
};
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const useSelectEditorContextState = ({
select,
onSelectChange,
}: Props): SelectEditorContextState => {
const { options, queryKey } = useAttrOptionsList({ selectId: select.id });
const optionsActions = useOptionsActions({ queryKey, select });
const onSelectChangeWithMsg = (values: UpdateAttrSelectSchema) => {
onSelectChange(values, () => {
notifications.success({
message: "Название справочника сохранено",
});
});
};
return {
select,
onSelectChange: onSelectChangeWithMsg,
options,
optionsActions,
};
};
export const [SelectEditorContextProvider, useSelectEditorContext] =
makeContext<SelectEditorContextState, Props>(
useSelectEditorContextState,
"SelectEditor"
);

View File

@ -0,0 +1,53 @@
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
AttrOptionSchema,
CreateAttrOptionSchema,
UpdateAttrOptionSchema,
} from "@/lib/client";
import {
createAttrOptionMutation,
deleteAttrOptionMutation,
updateAttrOptionMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
queryKey: any[];
};
export type AttrOptionsCrud = {
onCreate: (
data: Partial<CreateAttrOptionSchema>,
onSuccess?: () => void
) => void;
onUpdate: (
optionId: number,
option: UpdateAttrOptionSchema,
onSuccess?: () => void
) => void;
onDelete: (option: AttrOptionSchema, onSuccess?: () => void) => void;
};
export const useAttrOptionsCrud = ({ queryKey }: Props): AttrOptionsCrud => {
return useCrudOperations<
AttrOptionSchema,
UpdateAttrOptionSchema,
CreateAttrOptionSchema
>({
key: "getAttrOptions",
queryKey,
mutations: {
create: createAttrOptionMutation(),
update: updateAttrOptionMutation(),
delete: deleteAttrOptionMutation(),
},
getCreateEntity: data => ({
name: data.name!,
selectId: data.selectId!,
}),
getUpdateEntity: (old, update) => ({
...old,
name: update.name ?? old.name,
}),
getDeleteConfirmTitle: () => "Удаление опции",
});
};

View File

@ -0,0 +1,37 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AttrOptionSchema } from "@/lib/client";
import {
getAttrOptionsOptions,
getAttrOptionsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
selectId: number;
};
const useAttrOptionsList = ({ selectId }: Props) => {
const queryClient = useQueryClient();
const options = { path: { selectId } };
const { data, refetch } = useQuery(getAttrOptionsOptions(options));
const queryKey = getAttrOptionsQueryKey(options);
const setOptions = (options: AttrOptionSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: AttrOptionSchema[] }) => ({
...old,
items: options,
})
);
};
return {
options: data?.items ?? [],
setOptions,
refetch,
queryKey,
};
};
export default useAttrOptionsList;

View File

@ -0,0 +1,99 @@
import { Dispatch, SetStateAction, useState } from "react";
import { useForm, UseFormReturnType } from "@mantine/form";
import { useAttrOptionsCrud } from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useAttrOptionsCrud";
import {
AttrOptionSchema,
AttrSelectSchema,
CreateAttrOptionSchema,
} from "@/lib/client";
import { notifications } from "@/lib/notifications";
type Props = {
queryKey: any[];
select: AttrSelectSchema;
};
export type OptionsActions = {
isCreatingOption: boolean;
createOptionForm: UseFormReturnType<CreateAttrOptionSchema>;
onStartCreating: () => void;
onFinishCreating: () => void;
editingOptionsData: Map<number, string>;
setEditingOptionsData: Dispatch<SetStateAction<Map<number, string>>>;
onStartEditing: (option: AttrOptionSchema) => void;
onFinishEditing: (option: AttrOptionSchema) => void;
onDelete: (option: AttrOptionSchema) => void;
};
const useOptionsActions = ({ queryKey, select }: Props) => {
const [isCreatingOption, setIsCreatingOption] = useState<boolean>(false);
const [editingOptionsData, setEditingOptionsData] = useState<
Map<number, string>
>(new Map());
const createOptionForm = useForm<CreateAttrOptionSchema>({
initialValues: {
name: "",
selectId: select.id,
},
validate: {
name: name => !name && "Введите название",
},
});
const optionCrud = useAttrOptionsCrud({ queryKey });
const onStartCreating = () => {
setIsCreatingOption(true);
};
const onFinishCreating = () => {
if (createOptionForm.validate().hasErrors) return;
optionCrud.onCreate(createOptionForm.values, () => {
notifications.success({ message: "Опция успешно создана" });
createOptionForm.reset();
});
};
const onStartEditing = (option: AttrOptionSchema) => {
setEditingOptionsData(prev => {
prev.set(option.id, option.name);
return new Map(prev);
});
};
const onFinishEditing = (option: AttrOptionSchema) => {
if (!editingOptionsData.has(option.id)) return;
const newName = editingOptionsData.get(option.id);
if (!newName) {
notifications.error({ message: "Название не может быть пустым" });
return;
}
optionCrud.onUpdate(option.id, { name: newName }, () => {
notifications.success({ message: "Опция сохранена" });
setEditingOptionsData(prev => {
prev.delete(option.id);
return new Map(prev);
});
});
};
const onDelete = (option: AttrOptionSchema) => {
optionCrud.onDelete(option, () =>
notifications.success({ message: "Опция удалена" })
);
};
return {
isCreatingOption,
createOptionForm,
onStartCreating,
onFinishCreating,
editingOptionsData,
setEditingOptionsData,
onStartEditing,
onFinishEditing,
onDelete,
};
};
export default useOptionsActions;

View File

@ -0,0 +1,84 @@
"use client";
import React, { useMemo } from "react";
import { IconCheck, IconEdit, IconTrash } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Center, Flex, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrOptionSchema } from "@/lib/client";
const useSelectsTableColumns = () => {
const isMobile = useIsMobile();
const {
optionsActions: {
onStartEditing,
onFinishEditing,
onDelete,
editingOptionsData,
setEditingOptionsData,
},
} = useSelectEditorContext();
const onChange = (
e: React.ChangeEvent<HTMLInputElement>,
optionId: number
) => {
setEditingOptionsData(prev => {
prev.set(optionId, e.currentTarget.value);
return new Map(prev);
});
};
return useMemo(
() =>
[
{
title: "Название опции",
accessor: "name",
render: option =>
editingOptionsData.has(option.id) ? (
<TextInput
value={editingOptionsData.get(option.id)}
onChange={e => onChange(e, option.id)}
/>
) : (
option.name
),
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: option => (
<Flex gap={"xs"}>
{editingOptionsData.has(option.id) ? (
<ActionIconWithTip
onClick={() => onFinishEditing(option)}
tipLabel={"Сохранить"}>
<IconCheck />
</ActionIconWithTip>
) : (
<ActionIconWithTip
onClick={() => onStartEditing(option)}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
)}
<ActionIconWithTip
color={"red"}
onClick={() => onDelete(option)}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
</Flex>
),
},
] as DataTableColumn<AttrOptionSchema>[],
[isMobile, editingOptionsData]
);
};
export default useSelectsTableColumns;

View File

@ -0,0 +1,6 @@
enum AttributePageView {
ATTRIBUTES,
SELECTS,
}
export default AttributePageView;