Merge remote-tracking branch 'origin/cards'

# Conflicts:
#	src/client/index.ts
This commit is contained in:
2025-03-11 22:39:50 +03:00
396 changed files with 7349 additions and 4871 deletions

View File

@@ -6,7 +6,7 @@ import {
IconCalendarUser,
IconCoins,
IconCurrencyDollar,
IconQrcode,
IconQrcode, IconSubtask,
IconTopologyStar3,
IconUser,
} from "@tabler/icons-react";
@@ -21,6 +21,7 @@ import { RootState } from "../../redux/store.ts";
import OrganizationalStructureTab from "./tabs/OrganizationalStructureTab/OrganizationalStructureTab.tsx";
import { ReactNode } from "react";
import WorkShiftsPlanning from "./tabs/WorkShiftsPlanning/WorkShiftsPlanning.tsx";
import Attributes from "./tabs/Attributes/Attributes.tsx";
const AdminPage = () => {
const userRole = useSelector((state: RootState) => state.auth.role);
@@ -88,6 +89,13 @@ const AdminPage = () => {
Доходы и расходы
</Tabs.Tab>
)}
{isAdmin && (
<Tabs.Tab
value={"attributes"}
leftSection={<IconSubtask />}>
Атрибуты карточек
</Tabs.Tab>
)}
</Tabs.List>
{getTabPanel("users", <UsersTab />)}
{getTabPanel("rolesAndPositions", <OrganizationalStructureTab />)}
@@ -96,6 +104,7 @@ const AdminPage = () => {
{getTabPanel("workShiftsPlanning", <WorkShiftsPlanning />)}
{getTabPanel("workShifts", <WorkShiftsTab />)}
{getTabPanel("transactions", <TransactionsTab />)}
{getTabPanel("attributes", <Attributes />)}
</Tabs>
</PageBlock>
</div>

View File

@@ -0,0 +1,112 @@
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import useAttributesTableColumns from "./hooks/attributesTableColumns.tsx";
import useAttributesList from "../../../../hooks/useAttributesList.tsx";
import { ActionIcon, Flex, Group, Stack, Text, Tooltip } from "@mantine/core";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { AttributeSchema, AttributeService } from "../../../../client";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { MRT_TableOptions } from "mantine-react-table";
const Attributes = () => {
const columns = useAttributesTableColumns();
const { objects: attributes, refetch: refetchAttributes } = useAttributesList();
const onCreateAttributeClick = () => {
modals.openContextModal({
modal: "attributeModal",
title: "Создание атрибута",
withCloseButton: false,
innerProps: {
refetchAttributes,
},
});
};
const onEditAttributeClick = (attribute: AttributeSchema) => {
modals.openContextModal({
modal: "attributeModal",
title: "Редактирование атрибута",
withCloseButton: false,
innerProps: {
refetchAttributes,
attribute,
},
});
};
const deleteAttribute = (attribute: AttributeSchema) => {
AttributeService.delete({
attributeId: attribute.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
refetchAttributes();
})
.catch(err => console.log(err));
};
const onDeleteAttributeClick = (attribute: AttributeSchema) => {
modals.openConfirmModal({
title: "Удаление атрибута",
children: (
<Text>
Вы уверены, что хотите удалить атрибут "{attribute.label}"?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => deleteAttribute(attribute),
});
};
return (
<Stack>
<Group>
<InlineButton onClick={onCreateAttributeClick} mt={"md"}>
<IconPlus />
Добавить атрибут
</InlineButton>
</Group>
<BaseTable
data={attributes}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableBottomToolbar: false,
enableRowActions: true,
enableRowVirtualization: true,
mantineTableContainerProps: { style: { maxHeight: "86vh" } },
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditAttributeClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteAttributeClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<AttributeSchema>
}
/>
</Stack>
);
};
export default Attributes;

View File

@@ -0,0 +1,21 @@
import ObjectSelect, { ObjectSelectProps } from "../../../../../components/ObjectSelect/ObjectSelect.tsx";
import { AttributeTypeSchema } from "../../../../../client";
import useAttributeTypesList from "../hooks/useAttributeTypesList.tsx";
type Props = Omit<ObjectSelectProps<AttributeTypeSchema>, "data">;
const AttributeTypeSelect = (props: Props) => {
const { objects: attributeTypes } = useAttributeTypesList();
return (
<ObjectSelect
label={"Тип атрибута"}
getLabelFn={type => type.name}
getValueFn={type => type.id.toString()}
data={attributeTypes}
{...props}
/>
);
};
export default AttributeTypeSelect;

View File

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

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { AttributeSchema } from "../../../../../client";
import { IconCheck, IconX } from "@tabler/icons-react";
import { formatDate, formatDateTime } from "../../../../../types/utils.ts";
import { Box } from "@mantine/core";
const useAttributesTableColumns = () => {
return useMemo<MRT_ColumnDef<AttributeSchema>[]>(
() => [
{
header: "Название",
accessorKey: "label",
},
{
header: "Тип",
accessorKey: "type.name",
},
{
header: "Значение по умолчанию",
accessorKey: "defaultValue",
Cell: ({ cell, row }) => {
const value = cell.getValue();
if (value === null) return <>-</>;
const type = row.original.type.type;
if (type === "datetime") {
return formatDateTime(value as string);
}
if (type === "date") {
return formatDate(value as string);
}
if (type === "bool") {
return value ? <IconCheck /> : <IconX />;
}
return <>{value}</>;
},
},
{
header: "Синхронизировано в группе",
accessorKey: "isApplicableToGroup",
Cell: ({ cell }) => cell.getValue() ? (
<IconCheck />
) : (
<IconX />
),
},
{
header: "Может быть пустым",
accessorKey: "isNullable",
Cell: ({ cell }) => cell.getValue() ? (
<IconCheck />
) : (
<IconX />
),
},
{
header: "Описаниие",
accessorKey: "description",
Cell: ({ row }) => <Box>{row.original.description}</Box>,
},
],
[],
);
};
export default useAttributesTableColumns;

View File

@@ -0,0 +1,11 @@
import { AttributeService } from "../../../../../client";
import ObjectList from "../../../../../hooks/objectList.tsx";
const useAttributeTypesList = () =>
ObjectList({
queryFn: AttributeService.getTypes,
getObjectsFn: response => response.types,
queryKey: "getAllAttributeTypes",
});
export default useAttributeTypesList;

View File

@@ -0,0 +1,170 @@
import { ContextModalProps } from "@mantine/modals";
import { Button, Checkbox, Stack, Textarea, TextInput } from "@mantine/core";
import { useEffect, useState } from "react";
import AttributeTypeSelect from "../components/AttributeTypeSelect.tsx";
import { AttributeSchema, AttributeService } from "../../../../../client";
import { convertRussianToSnakeCase } from "../../../../CardsPage/drawers/ProjectEditDrawer/utils/stringConverting.ts";
import { useForm } from "@mantine/form";
import DefaultAttributeValueInput from "../components/DefaultAttributeValueInput.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type Props = {
refetchAttributes: () => void;
attribute?: AttributeSchema;
};
const AttributeModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "attribute" in innerProps;
const [isInitial, setIsInitial] = useState(true);
const [isNullableInputShown, setIsNullableInputShown] = useState(true);
const [copyTypeId, setCopyTypeId] = useState<number>();
const closeModal = () => context.closeContextModal(id);
const form = useForm<Partial<AttributeSchema>>({
initialValues: isEditing
? innerProps.attribute
: {
label: "",
name: "",
type: undefined,
isApplicableToGroup: false,
isNullable: false,
defaultValue: null,
description: "",
},
validate: {
label: label => !label?.trim() && "Название не заполнено",
name: name => !name?.trim() && "Название генерирует некорректный уникальный ключ. Измените название",
type: type => !type && "Тип атрибута не выбран",
},
});
useEffect(() => {
form.setFieldValue("name", convertRussianToSnakeCase(form.values.label ?? ""));
}, [form.values.label]);
useEffect(() => {
const type = form.values.type?.type;
setIsNullableInputShown(type !== "bool");
if (!isInitial) {
if (type === "bool") {
form.setFieldValue("isNullable", false);
form.setFieldValue("defaultValue", false);
} else {
form.setFieldValue("defaultValue", null);
}
}
setIsInitial(false);
setCopyTypeId(form.values.type?.id);
}, [form.values.type?.id]);
const validate = (): boolean => {
if (form.values.defaultValue === null && !form.values.isNullable) {
notifications.error({ message: "Укажите значение по умолчанию или разрешите пустое значение." });
return false;
}
return true;
};
const onCreate = (attribute: AttributeSchema) => {
AttributeService.create({
requestBody: {
attribute: {
...attribute,
typeId: attribute.type.id,
name: attribute.name.trim(),
label: attribute.label.trim(),
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
innerProps.refetchAttributes();
closeModal();
})
.catch(err => console.log(err));
};
const onChange = (attribute: AttributeSchema) => {
AttributeService.update({
requestBody: {
attribute: {
...attribute,
typeId: attribute.type.id,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
innerProps.refetchAttributes();
closeModal();
})
.catch(err => console.log(err));
};
const onSubmit = (values: AttributeSchema) => {
if (!validate()) return;
if (isEditing) {
onChange(values);
} else {
onCreate(values);
}
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values as AttributeSchema))}>
<Stack gap={"md"}>
<TextInput
label={"Название"}
{...form.getInputProps("label")}
/>
<TextInput
label={"Уникальный ключ"}
disabled
{...form.getInputProps("name")}
/>
<AttributeTypeSelect
disabled={isEditing}
{...form.getInputProps("type")}
/>
<Checkbox
label={"Значение синхронизировано в группе"}
{...form.getInputProps("isApplicableToGroup", { 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")}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Stack>
</form>
);
};
export default AttributeModal;

View File

@@ -176,7 +176,7 @@ export const TransactionsTab = () => {
/>
{totalPages > 1 && (
<Pagination
style={{ alignSelf: "flex-end" }}
style={{ alignSelf: "flex-end", marginTop: rem(10) }}
withEdges
onChange={event => setPage(event)}
value={page}

View File

@@ -0,0 +1 @@
export { CardPage } from "./ui/CardPage.tsx";

View File

@@ -0,0 +1,38 @@
import { useParams } from "@tanstack/react-router";
import { CardPageContextProvider, useCardPageContext } from "../../CardsPage/contexts/CardPageContext.tsx";
import ProductAndServiceTab from "../../../modules/cardModules/cardEditorTabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import React, { FC, useEffect } from "react";
import { CardService } from "../../../client";
export type Props = {
cardId: number;
};
const CardPageContent: FC<Props> = ({ cardId }) => {
const { setSelectedCard } = useCardPageContext();
useEffect(() => {
CardService.getCardById({ cardId }).then(card => {
setSelectedCard(card);
});
}, []);
return <ProductAndServiceTab />;
};
const CardPageWrapper: FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<CardPageContextProvider
refetchCards={async () => {
}}
>
{children}
</CardPageContextProvider>
);
};
export const CardPage = () => {
const { dealId } = useParams({ strict: false });
return (
<CardPageWrapper>
<CardPageContent cardId={parseInt(dealId || "-1")} />
</CardPageWrapper>
);
};

View File

@@ -0,0 +1,18 @@
import { TagsInput, TagsInputProps } from "@mantine/core";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
type Props = Omit<TagsInputProps, "data">
const CardTagsInput = (props: Props) => {
const { selectedProject } = useProjectsContext();
return (
<TagsInput
{...props}
data={selectedProject?.tags.map(tag => tag.name)}
label={"Теги"}
/>
);
};
export default CardTagsInput;

View File

@@ -1,58 +1,76 @@
import { ActionIcon, Flex, rem, Text } from "@mantine/core";
import { IconEdit, IconMenu2, IconMenuDeep } from "@tabler/icons-react";
import { IconEdit, IconMenu2, IconMenuDeep, IconPlus } from "@tabler/icons-react";
import { motion } from "framer-motion";
import styles from "../../ui/DealsPage.module.css";
import styles from "../../ui/CardsPage.module.css";
import PageBlock from "../../../../components/PageBlock/PageBlock.tsx";
import DisplayMode from "../../enums/DisplayMode.ts";
import { UseFormReturnType } from "@mantine/form";
import { DealsPageState } from "../../hooks/useDealsPageState.tsx";
import { CardsPageState } from "../../hooks/useCardsPageState.tsx";
import React from "react";
import { ProjectSchema } from "../../../../client";
import { modals } from "@mantine/modals";
import ObjectSelect from "../../../../components/ObjectSelect/ObjectSelect.tsx";
import DealsTableFiltersModal from "../../modals/DealsTableFiltersModal.tsx";
import CardsTableFiltersModal from "../../modals/CardsTableFiltersModal.tsx";
import { useProjectsEditorContext } from "../../contexts/ProjectsEditorContext.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { modals } from "@mantine/modals";
type Props = {
displayMode: DisplayMode;
setDisplayMode: React.Dispatch<React.SetStateAction<DisplayMode>>;
form: UseFormReturnType<DealsPageState>;
projects: ProjectSchema[];
refetchProjects: () => void;
form: UseFormReturnType<CardsPageState>;
}
const LeadsPageHeader = ({
const CardsPageHeader = ({
displayMode,
setDisplayMode,
form,
projects,
refetchProjects,
}: Props) => {
const openModal = () => {
const { openProjectsEditor } = useProjectsEditorContext();
const { selectedProject, setSelectedProject, projects, refetchProjects } = useProjectsContext();
const userRole = useSelector((state: RootState) => state.auth.role);
const isAdmin = userRole === "admin";
const handleCreateClick = () => {
modals.openContextModal({
modal: "projectsModal",
title: "Проекты",
modal: "createProjectModal",
title: "Создание проекта",
withCloseButton: false,
innerProps: {
onUpdate: refetchProjects,
refetchProjects,
},
});
};
const getHeaderInputsBoard = () => {
return (
<div className={styles["top-panel"]}
style={{
display: displayMode === DisplayMode.BOARD ? "flex" : "none",
}}>
<ActionIcon
size={"lg"}
onClick={openModal}
variant={"default"}>
<IconEdit />
</ActionIcon>
<div
className={styles["top-panel"]}
style={{
display: displayMode === DisplayMode.BOARD ? "flex" : "none",
}}
>
{isAdmin && (
<>
<ActionIcon
size={"lg"}
onClick={handleCreateClick}
variant={"default"}>
<IconPlus />
</ActionIcon>
<ActionIcon
size={"lg"}
onClick={openProjectsEditor}
variant={"default"}>
<IconEdit />
</ActionIcon>
</>
)}
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}
{...form.getInputProps("project")}
value={selectedProject}
onChange={setSelectedProject}
/>
</div>
);
@@ -66,7 +84,7 @@ const LeadsPageHeader = ({
display: displayMode === DisplayMode.TABLE ? "flex" : "none",
}}
>
<DealsTableFiltersModal
<CardsTableFiltersModal
form={form}
projects={projects}
/>
@@ -124,4 +142,4 @@ const LeadsPageHeader = ({
);
};
export default LeadsPageHeader;
export default CardsPageHeader;

View File

@@ -1,24 +1,24 @@
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { DealService, DealSummary } from "../../../../client";
import { CardService, CardSummary } from "../../../../client";
import { FC } from "react";
import useDealsTableColumns from "./columns.tsx";
import useCardsTableColumns from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { useDealPageContext } from "../../contexts/DealPageContext.tsx";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
type RestProps = {
viewOnly?: boolean;
};
type Props = CRUDTableProps<DealSummary> & RestProps;
type Props = CRUDTableProps<CardSummary> & RestProps;
const DealsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) => {
const columns = useDealsTableColumns();
const { setSelectedDeal } = useDealPageContext();
const onEditClick = (dealSummary: DealSummary) => {
DealService.getDealById({ dealId: dealSummary.id }).then(deal => {
setSelectedDeal(deal);
const CardsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) => {
const columns = useCardsTableColumns();
const { setSelectedCard } = useCardPageContext();
const onEditClick = (cardSummary: CardSummary) => {
CardService.getCardById({ cardId: cardSummary.id }).then(card => {
setSelectedCard(card);
});
};
@@ -48,10 +48,10 @@ const DealsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) =
</Tooltip>
</Flex>
),
} as MRT_TableOptions<DealSummary>
} as MRT_TableOptions<CardSummary>
}
/>
);
};
export default DealsTable;
export default CardsTable;

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { DealSummary } from "../../../../client";
import { CardSummary } from "../../../../client";
import { ActionIcon, Image } from "@mantine/core";
const useDealsTableColumns = () => {
return useMemo<MRT_ColumnDef<DealSummary>[]>(
const useCardsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardSummary>[]>(
() => [
{
accessorKey: "id",
@@ -53,4 +53,4 @@ const useDealsTableColumns = () => {
);
};
export default useDealsTableColumns;
export default useCardsTableColumns;

View File

@@ -0,0 +1,70 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
import { CardSchema, CardService } from "../../../client";
type CardPageContextState = {
selectedCard?: CardSchema;
setSelectedCard: (card: CardSchema | undefined) => void;
refetchCards: () => Promise<void>;
refetchCard: () => void;
};
const CardPageContext = createContext<CardPageContextState | undefined>(
undefined,
);
type CardPageContextStateProps = {
refetchCards: () => Promise<void>;
defaultCardId?: number;
}
const useCardPageContextState = (props: CardPageContextStateProps) => {
const { refetchCards, defaultCardId } = props;
const [selectedCard, setSelectedCard] = useState<CardSchema | undefined>(undefined);
const refetchCard = () => {
const cardId = selectedCard?.id ?? defaultCardId;
if (!cardId) return;
CardService.getCardById({ cardId }).then(card => {
setSelectedCard(card);
});
};
useEffect(() => {
refetchCard();
}, []);
return {
selectedCard,
setSelectedCard,
refetchCards,
refetchCard,
};
};
type CardPageContextProviderProps = {
children: React.ReactNode;
} & CardPageContextStateProps;
export const CardPageContextProvider: FC<CardPageContextProviderProps> = ({
children,
...props
}) => {
const state = useCardPageContextState(props);
return (
<CardPageContext.Provider value={state}>
{children}
</CardPageContext.Provider>
);
};
export const useCardPageContext = () => {
const context = useContext(CardPageContext);
if (!context) {
throw new Error(
"useCardPageContext must be used within a CardPageContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,91 @@
import React, { createContext, FC, useContext, useState } from "react";
import { CardSummary } from "../../../client";
import DragState from "../enums/DragState.ts";
import useCardsDnd from "../../../components/Dnd/Cards/CardsDndColumn/hooks/useCardsDnd.tsx";
import useStatusesDnd from "../../../components/Dnd/Statuses/Statuses/hooks/useStatusesDnd.tsx";
import { DragStart, DropResult } from "@hello-pangea/dnd";
import useBoardsDnd from "../../../components/Dnd/Boards/Boards/hooks/useBoardsDnd.tsx";
type DndContextState = {
summaries: CardSummary[],
dragState: DragState,
onDragStart: (start: DragStart) => void,
onDragEnd: (result: DropResult) => Promise<void>,
};
const DndContext = createContext<DndContextState | undefined>(undefined);
type DndContextProps = {
summariesRaw: CardSummary[];
refetchSummaries: () => void;
}
const useDndContextState = ({
summariesRaw,
refetchSummaries,
}: DndContextProps) => {
const [dragState, setDragState] = useState<DragState>(DragState.DRAG_ENDED);
const {
summaries,
onCardDragEnd,
} = useCardsDnd({
summariesRaw,
refetchSummaries,
});
const { onStatusDragEnd } = useStatusesDnd();
const { onBoardDragEnd } = useBoardsDnd();
const onDragEnd = async (result: DropResult) => {
setDragState(DragState.DRAG_ENDED);
if (result.draggableId.includes("status")) {
return onStatusDragEnd(result);
}
if (result.draggableId.includes("board")) {
return onBoardDragEnd(result);
}
return onCardDragEnd(result);
};
const onDragStart = (start: DragStart) => {
if (start.source.droppableId.includes("status")) {
setDragState(DragState.DRAG_STATUS);
} else if (start.source.droppableId.includes("board")) {
setDragState(DragState.DRAG_BOARD);
} else {
setDragState(DragState.DRAG_CARD);
}
};
return {
summaries,
dragState,
onDragStart,
onDragEnd,
};
};
type DndContextProviderProps = {
children: React.ReactNode;
} & DndContextProps;
export const DndContextProvider: FC<DndContextProviderProps> = ({ children, ...props }) => {
const state = useDndContextState(props);
return (
<DndContext.Provider value={state}>
{children}
</DndContext.Provider>
);
};
export const useDndContext = () => {
const context = useContext(DndContext);
if (!context) {
throw new Error(
"useDndContext must be used within a DndContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,71 @@
import { createContext, Dispatch, FC, SetStateAction, useContext, useState } from "react";
import { CardSchema, CardService } from "../../../client";
import { useDisclosure } from "@mantine/hooks";
type PrefillCardContextState = {
prefillOpened: boolean;
prefillOnClose: () => void;
prefillOnOpen: () => void;
selectedPrefillCard?: CardSchema;
selectPrefillCard: (cardId: number) => void;
prefillCard?: CardSchema;
setPrefillCard: Dispatch<SetStateAction<CardSchema | undefined>>;
};
const PrefillCardContext = createContext<PrefillCardContextState | undefined>(
undefined
);
const usePrefillCardContextState = () => {
const [selectedPrefillCard, setSelectedPrefillCard] = useState<CardSchema | undefined>(
undefined,
);
const [prefillCard, setPrefillCard] = useState<CardSchema | undefined>(
undefined,
);
const [prefillOpened, { open, close }] = useDisclosure(false);
const prefillOnClose = close;
const prefillOnOpen = open;
const selectPrefillCard = (cardId: number) => {
CardService.getCardById({ cardId }).then(card => {
setSelectedPrefillCard(card);
});
}
return {
prefillOpened,
prefillOnClose,
prefillOnOpen,
selectedPrefillCard,
selectPrefillCard,
prefillCard,
setPrefillCard,
};
};
type PrefillCardContextProviderProps = {
children: React.ReactNode;
};
export const PrefillCardContextProvider: FC<PrefillCardContextProviderProps> = ({
children,
}) => {
const state = usePrefillCardContextState();
return (
<PrefillCardContext.Provider value={state}>
{children}
</PrefillCardContext.Provider>
);
};
export const usePrefillCardContext = () => {
const context = useContext(PrefillCardContext);
if (!context) {
throw new Error(
"usePrefillCardContext must be used within a PrefillCardContextProvider"
);
}
return context;
};

View File

@@ -1,14 +1,14 @@
import { createContext, FC, useContext, useState } from "react";
import React, { createContext, FC, useContext, useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { DealsWithExcelForm, ProductExcelData } from "../drawers/PrefillDealWithExcelDrawer/types.tsx";
import { CardsWithExcelForm, ProductExcelData } from "../drawers/PrefillCardWithExcelDrawer/types.tsx";
import { FileWithPath } from "@mantine/dropzone";
import { notifications } from "../../../shared/lib/notifications.ts";
import { DealService, type ProductFromExcelSchema, ProductSchema, StatusSchema } from "../../../client";
import { CardService, type ProductFromExcelSchema, ProductSchema, StatusSchema } from "../../../client";
import UseExcelDropzone from "../../../types/UseExcelDropzone.tsx";
import { useForm, UseFormReturnType } from "@mantine/form";
import { useDealPageContext } from "./DealPageContext.tsx";
import { useCardPageContext } from "./CardPageContext.tsx";
type PrefillDealsWithExcelContextState = {
type PrefillCardsWithExcelContextState = {
prefillWithExcelOpened: boolean;
prefillWithExcelOnClose: () => void;
prefillWithExcelOnOpen: () => void;
@@ -16,18 +16,18 @@ type PrefillDealsWithExcelContextState = {
onProductSelectChange: (barcode: string, selectedProduct: ProductSchema) => void,
onDrop: (files: FileWithPath[]) => void;
excelDropzone: UseExcelDropzone;
createDeals: (values: DealsWithExcelForm, status: StatusSchema) => void;
form: UseFormReturnType<DealsWithExcelForm>;
createCards: (values: CardsWithExcelForm, status: StatusSchema) => void;
form: UseFormReturnType<CardsWithExcelForm>;
errors: string[];
};
const PrefillDealsWithExcelContext = createContext<PrefillDealsWithExcelContextState | undefined>(
const PrefillCardsWithExcelContext = createContext<PrefillCardsWithExcelContextState | undefined>(
undefined,
);
const usePrefillDealsWithExcelContextState = () => {
const usePrefillCardsWithExcelContextState = () => {
const [prefillWithExcelOpened, { open, close }] = useDisclosure(false);
const { refetchDeals } = useDealPageContext();
const { refetchCards } = useCardPageContext();
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const excelDropzone: UseExcelDropzone = {
@@ -35,7 +35,7 @@ const usePrefillDealsWithExcelContextState = () => {
setIsLoading,
};
const form = useForm<DealsWithExcelForm>({
const form = useForm<CardsWithExcelForm>({
validate: {
client: client => !client && "Выберите клиента",
},
@@ -51,7 +51,7 @@ const usePrefillDealsWithExcelContextState = () => {
const file = files[0];
setIsLoading(true);
DealService.parseDealsExcel({
CardService.parseDealsExcel({
formData: {
upload_file: file,
},
@@ -91,7 +91,7 @@ const usePrefillDealsWithExcelContextState = () => {
form.reset();
};
const createDeals = (values: DealsWithExcelForm, status: StatusSchema) => {
const createCards = (values: CardsWithExcelForm, status: StatusSchema) => {
const products: ProductFromExcelSchema[] = barcodeProductsMap.entries().map(([, productData]) => {
return {
productId: productData.selectedProduct!.id,
@@ -99,7 +99,7 @@ const usePrefillDealsWithExcelContextState = () => {
};
}).toArray();
DealService.createDealsExcel({
CardService.createDealsExcel({
requestBody: {
products,
clientId: values.client?.id ?? -1,
@@ -109,7 +109,7 @@ const usePrefillDealsWithExcelContextState = () => {
.then(({ ok, message }) => {
notifications.guess(ok, { message });
if (ok) prefillWithExcelOnClose();
refetchDeals();
refetchCards();
})
.catch(err => console.log(err));
};
@@ -122,30 +122,30 @@ const usePrefillDealsWithExcelContextState = () => {
onProductSelectChange,
onDrop,
excelDropzone,
createDeals,
createCards,
form,
errors,
};
};
type PrefillDealsWithExcelContextProviderProps = {
type PrefillCardsWithExcelContextProviderProps = {
children: React.ReactNode;
};
export const PrefillDealsWithExcelContextProvider: FC<PrefillDealsWithExcelContextProviderProps> = ({ children }) => {
const state = usePrefillDealsWithExcelContextState();
export const PrefillCardsWithExcelContextProvider: FC<PrefillCardsWithExcelContextProviderProps> = ({ children }) => {
const state = usePrefillCardsWithExcelContextState();
return (
<PrefillDealsWithExcelContext.Provider value={state}>
<PrefillCardsWithExcelContext.Provider value={state}>
{children}
</PrefillDealsWithExcelContext.Provider>
</PrefillCardsWithExcelContext.Provider>
);
};
export const usePrefillDealsWithExcelContext = () => {
const context = useContext(PrefillDealsWithExcelContext);
export const usePrefillCardsWithExcelContext = () => {
const context = useContext(PrefillCardsWithExcelContext);
if (!context) {
throw new Error(
"usePrefillDealsWithExcelContext must be used within a PrefillDealsWithExcelContextProvider",
"usePrefillCardsWithExcelContext must be used within a PrefillCardsWithExcelContextProvider",
);
}
return context;

View File

@@ -0,0 +1,46 @@
import React, { createContext, FC, useContext } from "react";
import { useDisclosure } from "@mantine/hooks";
type ProjectsEditorContextState = {
openedProjectsEditor: boolean;
openProjectsEditor: () => void;
closeProjectsEditor: () => void;
};
const ProjectsEditorContext = createContext<ProjectsEditorContextState | undefined>(
undefined,
);
const useProjectsEditorContextState = () => {
const [opened, { open, close }] = useDisclosure(false);
return {
openedProjectsEditor: opened,
openProjectsEditor: open,
closeProjectsEditor: close,
};
};
type ProjectsEditorContextProviderProps = {
children: React.ReactNode;
};
export const ProjectsEditorContextProvider: FC<ProjectsEditorContextProviderProps> = ({ children }) => {
const state = useProjectsEditorContextState();
return (
<ProjectsEditorContext.Provider value={state}>
{children}
</ProjectsEditorContext.Provider>
);
};
export const useProjectsEditorContext = () => {
const context = useContext(ProjectsEditorContext);
if (!context) {
throw new Error(
"useProjectEditorContext must be used within a ProjectEditorContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,130 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { FC, ReactNode, useEffect } from "react";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import { IconCalendarUser, IconSettings } from "@tabler/icons-react";
import CardStatusChangeTable from "./tabs/CardStatusChangeTable/CardStatusChangeTable.tsx";
import GeneralTab from "./tabs/GeneralTab/GeneralTab.tsx";
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useModulesContext } from "../../../../modules/context/ModulesContext.tsx";
const useCardStatusChangeState = () => {
const { selectedCard } = useCardPageContext();
return {
statusHistory: selectedCard?.statusHistory || [],
};
};
const CardEditDrawerStatusChangeTable = () => {
const { statusHistory } = useCardStatusChangeState();
return <CardStatusChangeTable items={statusHistory} />;
};
const useCardEditDrawerState = () => {
const { selectedCard, setSelectedCard } = useCardPageContext();
return {
isVisible: selectedCard !== undefined,
onClose: () => setSelectedCard(undefined),
};
};
const CardEditDrawer: FC = () => {
const { isVisible, onClose } = useCardEditDrawerState();
const queryClient = useQueryClient();
const { modules } = useModulesContext();
useEffect(() => {
if (isVisible) return;
queryClient.invalidateQueries({ queryKey: ["getCardSummaries"] });
}, [isVisible]);
const getTabPanel = (value: string, component: ReactNode) => {
return (
<Tabs.Panel key={value} value={value}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<Box
h={"100%"}
w={"100%"}
p={rem(10)}>
{component}
</Box>
</motion.div>
</Tabs.Panel>
);
};
const getTabs = () => {
const moduleTabs = modules.map(module => (
<Tabs.Tab
key={module.info.key}
value={module.info.key}
leftSection={module.info.icon}
>
{module.info.label}
</Tabs.Tab>
));
return (
<>{moduleTabs}</>
);
};
const getTabPanels = () => {
const moduleTabPanels = modules.map(
module => getTabPanel(module.info.key, module.tab),
);
return (
<>{moduleTabPanels}</>
);
};
return (
<Drawer
size={"calc(100vw - 150px)"}
position={"right"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isVisible}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Tabs
defaultValue={"general"}
flex={1}
variant={"outline"}
orientation={"vertical"}
keepMounted={false}>
<Tabs.List>
<Tabs.Tab
value={"general"}
leftSection={<IconSettings />}>
Общее
</Tabs.Tab>
<Tabs.Tab
value={"history"}
leftSection={<IconCalendarUser />}>
История
</Tabs.Tab>
{getTabs()}
</Tabs.List>
{getTabPanel("general", <GeneralTab />)}
{getTabPanel("history", <CardEditDrawerStatusChangeTable />)}
{getTabPanels()}
</Tabs>
</Drawer>
);
};
export default CardEditDrawer;

View File

@@ -1,13 +1,13 @@
import { DealStatusHistorySchema } from "../../../../client";
import { CardStatusHistorySchema } from "../../../../../../client";
import { useDealStatusChangeTableColumns } from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { BaseTable } from "../../../../../../components/BaseTable/BaseTable.tsx";
import { FC } from "react";
type Props = {
items: DealStatusHistorySchema[];
items: CardStatusHistorySchema[];
};
const DealStatusChangeTable: FC<Props> = (props: Props) => {
const CardStatusChangeTable: FC<Props> = (props: Props) => {
const { items } = props;
return (
@@ -26,4 +26,4 @@ const DealStatusChangeTable: FC<Props> = (props: Props) => {
/>
);
};
export default DealStatusChangeTable;
export default CardStatusChangeTable;

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { DealStatusHistorySchema } from "../../../../client";
import { CardStatusHistorySchema } from "../../../../../../client";
import { Spoiler, Text } from "@mantine/core";
export const useDealStatusChangeTableColumns = () => {
return useMemo<MRT_ColumnDef<DealStatusHistorySchema>[]>(
return useMemo<MRT_ColumnDef<CardStatusHistorySchema>[]>(
() => [
{
accessorKey: "changedAt",

View File

@@ -0,0 +1,274 @@
import { FC, useState } from "react";
import { useCardPageContext } from "../../../../contexts/CardPageContext.tsx";
import {
Button,
Checkbox,
Divider,
Fieldset,
Flex,
Group,
rem,
ScrollArea,
Stack,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { CardSchema, CardService, ClientService, ProjectSchema, StatusSchema } from "../../../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
import { ButtonCopyControlled } from "../../../../../../components/ButtonCopyControlled/ButtonCopyControlled.tsx";
import { useClipboard } from "@mantine/hooks";
import ProjectSelect from "../../../../../../components/ProjectSelect/ProjectSelect.tsx";
import BoardSelect from "../../../../../../components/BoardSelect/BoardSelect.tsx";
import CardStatusSelect from "../../../../../../components/CardStatusSelect/CardStatusSelect.tsx";
import CardAttributeFields from "../../../../../../components/CardAttributeFields/CardAttributeFields.tsx";
import getAttributesFromCard from "../../../../../../components/CardAttributeFields/utils/getAttributesFromCard.ts";
import CardTagsInput from "../../../../components/CardTagsInput/CardTagsInput.tsx";
type Props = {
card: CardSchema;
};
type Attributes = {
[key: string]: number | boolean | string;
};
export type CardGeneralFormType = Omit<CardSchema, "statusHistory" | "services" | "products">;
const Content: FC<Props> = ({ card }) => {
const { setSelectedCard } = useCardPageContext();
const clipboard = useClipboard();
const queryClient = useQueryClient();
const [project, setProject] = useState<ProjectSchema | null>(card.board.project);
const [cardTags, setCardTags] = useState<string[]>(card.tags?.map(tag => tag.name) ?? []);
const getInitialValues = (card: CardSchema): CardGeneralFormType => {
return {
...card,
...getAttributesFromCard(card),
};
};
let initialValues = getInitialValues(card);
const form = useForm<CardGeneralFormType>({
initialValues,
validate: {
name: (value: string) =>
value.length > 0
? null
: "Название не может быть пустым",
status: (value: StatusSchema) =>
!value && "Статус не выбран",
},
});
const updateCardInfo = async (values: CardGeneralFormType) => {
const formCardAttrs = values as unknown as Attributes;
const attributes = project?.attributes.reduce((attrs, projectAttr) => {
return {
...attrs,
[projectAttr.name]: formCardAttrs[projectAttr.name],
};
}, {});
return CardService.updateCardGeneralInfo({
requestBody: {
cardId: card.id,
data: {
...values,
statusId: values.status.id,
boardId: values.board.id,
clientId: values.client?.id ?? null,
attributes,
tags: cardTags,
},
},
}).then(({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
CardService.getCardById({ cardId: card.id }).then(data => {
setSelectedCard(data);
initialValues = getInitialValues(data);
form.setValues(initialValues);
queryClient.invalidateQueries({
queryKey: ["getCardSummaries"],
});
});
});
};
const updateClientInfo = async (values: CardGeneralFormType) => {
if (!values.client) return;
return ClientService.updateClient({
requestBody: {
data: values.client,
},
}).then(({ ok, message }) => notifications.guess(ok, { message }));
};
const handleSubmit = async (values: CardGeneralFormType) => {
// Updating client info if there changes
if (!isEqual(values.client, card.client) && values.client?.id === card.clientId) {
await updateClientInfo(values);
}
await updateCardInfo(values);
};
const onCopyGuestUrlClick = () => {
CardService.createDealGuestUrl({
requestBody: {
cardId: card.id,
},
}).then(({ ok, message, url }) => {
if (!ok) notifications.guess(ok, { message });
clipboard.copy(`${window.location.origin}/${url}`);
});
};
const cancelChanges = () => {
form.reset();
setCardTags(card.tags?.map(tag => tag.name) ?? []);
};
const isEqualValues = () => {
const initialCardTagsSet = new Set(card.tags?.map(tag => tag.name) ?? []);
const tagsEqual = initialCardTagsSet.size === cardTags.length &&
cardTags.every(element => initialCardTagsSet.has(element));
return isEqual(initialValues, form.values) && tagsEqual;
};
return (
<form onSubmit={form.onSubmit(values => handleSubmit(values))}>
<Flex
direction={"column"}
h={"95vh"}
>
<ScrollArea>
<Stack>
<Fieldset legend={`Общие параметры [ID: ${card.id}]`}>
<Flex
direction={"column"}
gap={rem(10)}
>
<TextInput
placeholder={"Название сделки"}
label={"Название сделки"}
{...form.getInputProps("name")}
/>
<TextInput
disabled
placeholder={"Дата создания"}
label={"Дата создания"}
value={new Date(card.createdAt).toLocaleString(
"ru-RU",
)}
/>
<ProjectSelect
value={project}
onChange={setProject}
label={"Проект"}
disabled
/>
<BoardSelect
project={project}
{...form.getInputProps("board")}
label={"Доска"}
/>
<CardStatusSelect
board={form.values.board}
{...form.getInputProps("status")}
label={"Статус"}
/>
<Textarea
h={rem(120)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
label={"Коментарий"}
placeholder={"Введите коментарий"}
{...form.getInputProps("comment")}
/>
{project && project?.tags.length > 0 && (
<CardTagsInput
value={cardTags}
onChange={setCardTags}
/>
)}
{project && (
<CardAttributeFields
project={project}
form={form}
/>
)}
</Flex>
</Fieldset>
</Stack>
</ScrollArea>
<Flex
mt={"md"}
gap={rem(10)}
align={"center"}
justify={"flex-end"}>
<Flex
align={"center"}
gap={rem(10)}
justify={"center"}>
<ButtonCopyControlled
onCopyClick={onCopyGuestUrlClick}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
copied={clipboard.copied}
>
Ссылка на редактирование
</ButtonCopyControlled>
<Flex gap={rem(10)}>
<Checkbox
label={"Завершена"}
{...form.getInputProps("isCompleted", { type: "checkbox" })}
/>
<Checkbox
label={"Удалена"}
{...form.getInputProps("isDeleted", { type: "checkbox" })}
/>
</Flex>
</Flex>
<Divider orientation={"vertical"} />
<Group
align={"center"}
justify={"center"}>
<Button
color={"red"}
type={"reset"}
disabled={isEqualValues()}
onClick={cancelChanges}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqualValues()}>
Сохранить изменения
</Button>
</Group>
</Flex>
</Flex>
</form>
);
};
const GeneralTab: FC = () => {
const { selectedCard } = useCardPageContext();
if (!selectedCard) return <>No card selected</>;
return (
<Content card={selectedCard} />
);
};
export default GeneralTab;

View File

@@ -5,20 +5,20 @@
max-height: 95vh;
}
.deal-container {
.card-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.deal-container-wrapper {
.card-container-wrapper {
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(10);
}
.deal-container-buttons {
.card-container-buttons {
gap: rem(10);
display: flex;
flex-direction: column;

View File

@@ -1,16 +1,16 @@
import { FC, useEffect } from "react";
import { Button, Drawer, Flex, rem, TextInput } from "@mantine/core";
import DealsTable from "./components/tables/DealsTable/DealsTable.tsx";
import CardsTable from "./components/tables/CardsTable/CardsTable.tsx";
import Preview from "./components/Preview/Preview.tsx";
import styles from "./DealPrefillDrawer.module.css";
import styles from "./CardPrefillDrawer.module.css";
import BaseMarketplaceSelect from "../../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import usePrefillDeal from "./hooks/usePrefillDeal.tsx";
import usePrefillCard from "./hooks/usePrefillCard.tsx";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { usePrefillDealContext } from "../../contexts/PrefillDealContext.tsx";
import { usePrefillCardContext } from "../../contexts/PrefillCardContext.tsx";
const DealPrefillDrawer: FC = () => {
const { prefillOpened, prefillOnClose, selectedPrefillDeal, setPrefillDeal, prefillDeal } = usePrefillDealContext();
const { data, form } = usePrefillDeal();
const CardPrefillDrawer: FC = () => {
const { prefillOpened, prefillOnClose, selectedPrefillCard, setPrefillCard, prefillCard } = usePrefillCardContext();
const { data, form } = usePrefillCard();
useEffect(() => {
if (prefillOpened) return;
@@ -33,8 +33,8 @@ const DealPrefillDrawer: FC = () => {
},
}}
>
<div className={styles["deal-container"]}>
<div className={styles["deal-container-wrapper"]}>
<div className={styles["card-container"]}>
<div className={styles["card-container-wrapper"]}>
<div className={styles["top-panel"]}>
<TextInput
placeholder={"Введите название / id"}
@@ -49,22 +49,22 @@ const DealPrefillDrawer: FC = () => {
{...form.getInputProps("marketplace")}
/>
</div>
<DealsTable items={data} />
<CardsTable items={data} />
<Flex direction={"row"} gap="sm">
<Button mt={10} w={"100%"} onClick={() => {
if (!selectedPrefillDeal) {
notifications.error({ message: "Сделка не выбрана." });
if (!selectedPrefillCard) {
notifications.error({ message: "Карточка не выбрана." });
return;
}
setPrefillDeal(selectedPrefillDeal);
setPrefillCard(selectedPrefillCard);
prefillOnClose();
}}>
Предзаполнить
</Button>
{
prefillDeal &&
prefillCard &&
<Button mt={10} w={"100%"} variant={"outline"} onClick={() => {
setPrefillDeal(undefined);
setPrefillCard(undefined);
notifications.success({ message: "Предзаполнение отменено." });
prefillOnClose();
}}>
@@ -79,4 +79,4 @@ const DealPrefillDrawer: FC = () => {
);
};
export default DealPrefillDrawer;
export default CardPrefillDrawer;

View File

@@ -11,21 +11,21 @@
flex: 2;
}
.deal-container {
.card-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.deal-container-wrapper {
.card-container-wrapper {
width: 100%;
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(10);
}
.deal-container-buttons {
.card-container-buttons {
gap: rem(10);
display: flex;
flex-direction: column;

View File

@@ -1,16 +1,16 @@
import { FC } from "react";
import styles from "./Preview.module.css";
import { ScrollArea, Skeleton, Title } from "@mantine/core";
import DealServicesTable from "../tables/DealServicesTable/DealServicesTable.tsx";
import CardServicesTable from "../tables/CardServicesTable/CardServicesTable.tsx";
import ProductPreview from "../ProductPreview/ProductPreview.tsx";
import { usePrefillDealContext } from "../../../../contexts/PrefillDealContext.tsx";
import { usePrefillCardContext } from "../../../../contexts/PrefillCardContext.tsx";
const Preview: FC = () => {
const { selectedPrefillDeal } = usePrefillDealContext();
const { selectedPrefillCard } = usePrefillCardContext();
const getTotalPrice = () => {
if (!selectedPrefillDeal) return 0;
const productServicesPrice = selectedPrefillDeal.products.reduce(
if (!selectedPrefillCard) return 0;
const productServicesPrice = selectedPrefillCard.products.reduce(
(acc, row) =>
acc +
row.services.reduce(
@@ -19,28 +19,28 @@ const Preview: FC = () => {
),
0,
);
const dealServicesPrice = selectedPrefillDeal.services.reduce(
const cardServicesPrice = selectedPrefillCard.services.reduce(
(acc, row) => acc + row.price * row.quantity,
0,
);
return dealServicesPrice + productServicesPrice;
return cardServicesPrice + productServicesPrice;
};
return (
<div className={styles["container"]}>
<div className={styles["deal-container-wrapper"]}>
<div className={styles["card-container-wrapper"]}>
<ScrollArea offsetScrollbars={"y"} w={"100%"}>
<div style={{ height: "93vh" }}>
<Skeleton visible={!selectedPrefillDeal}>
<Skeleton visible={!selectedPrefillCard}>
<Title order={4} mb={18}>
Общая стоимость всех услуг:{" "}
{getTotalPrice().toLocaleString("ru")}
</Title>
<DealServicesTable items={selectedPrefillDeal?.services} />
<CardServicesTable items={selectedPrefillCard?.services} />
<div className={styles["products-list"]}>
{selectedPrefillDeal?.products.map(product => (
{selectedPrefillCard?.products.map(product => (
<ProductPreview
key={product.product.id}
product={product}

View File

@@ -1,13 +1,13 @@
import { FC } from "react";
import { DealProductSchema, ProductSchema } from "../../../../../../client";
import { CardProductSchema, ProductSchema } from "../../../../../../client";
import { Image, rem, Text, Title } from "@mantine/core";
import { isNil } from "lodash";
import { ProductFieldNames } from "../../../../tabs/ProductAndServiceTab/components/ProductView/ProductView.tsx";
import { ProductFieldNames } from "../../../../../../modules/cardModules/cardEditorTabs/ProductAndServiceTab/components/ProductView/ProductView.tsx";
import ProductServicesTable from "../tables/ProductServicesTable/ProductServicesTable.tsx";
import styles from "./ProductPreview.module.css";
type Props = {
product: DealProductSchema;
product: CardProductSchema;
};
const ProductPreview: FC<Props> = ({ product }) => {

View File

@@ -1,16 +1,16 @@
import { FC } from "react";
import { Flex, rem, Title } from "@mantine/core";
import { DealServiceSchema, DealSummary } from "../../../../../../../client";
import useDealServicesTableColumns from "./columns.tsx";
import { CardServiceSchema, CardSummary } from "../../../../../../../client";
import useCardServicesTableColumns from "./columns.tsx";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { MRT_TableOptions } from "mantine-react-table";
type Props = {
items?: DealServiceSchema[];
items?: CardServiceSchema[];
};
const DealServicesTable: FC<Props> = ({ items }) => {
const columns = useDealServicesTableColumns();
const CardServicesTable: FC<Props> = ({ items }) => {
const columns = useCardServicesTableColumns();
return (
<Flex
@@ -33,7 +33,7 @@ const DealServicesTable: FC<Props> = ({ items }) => {
enableColumnActions: false,
enablePagination: false,
enableBottomToolbar: false,
} as MRT_TableOptions<DealSummary>
} as MRT_TableOptions<CardSummary>
}
/>
@@ -55,4 +55,4 @@ const DealServicesTable: FC<Props> = ({ items }) => {
</Flex>
);
};
export default DealServicesTable;
export default CardServicesTable;

View File

@@ -1,9 +1,9 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { DealServiceSchema } from "../../../../../../../client";
import { CardServiceSchema } from "../../../../../../../client";
const useDealServicesTableColumns = () => {
return useMemo<MRT_ColumnDef<DealServiceSchema>[]>(
const useCardServicesTableColumns = () => {
return useMemo<MRT_ColumnDef<CardServiceSchema>[]>(
() => [
{
header: "Название",
@@ -27,4 +27,4 @@ const useDealServicesTableColumns = () => {
);
};
export default useDealServicesTableColumns;
export default useCardServicesTableColumns;

View File

@@ -1,21 +1,21 @@
import { FC, useEffect } from "react";
import useDealsTableColumns from "./columns.tsx";
import { DealSummary } from "../../../../../../../client";
import useCardsTableColumns from "./columns.tsx";
import { CardSummary } from "../../../../../../../client";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { usePrefillDealContext } from "../../../../../contexts/PrefillDealContext.tsx";
import { usePrefillCardContext } from "../../../../../contexts/PrefillCardContext.tsx";
type Props = {
items: DealSummary[];
items: CardSummary[];
};
const DealsTable: FC<Props> = ({ items }) => {
const { selectPrefillDeal } = usePrefillDealContext();
const columns = useDealsTableColumns();
const CardsTable: FC<Props> = ({ items }) => {
const { selectPrefillCard } = usePrefillCardContext();
const columns = useCardsTableColumns();
const defaultSorting = [{ id: "createdAt", desc: false }];
useEffect(() => {
if (items.length < 1) return;
selectPrefillDeal(items[0].id);
selectPrefillCard(items[0].id);
}, []);
return (
@@ -45,4 +45,4 @@ const DealsTable: FC<Props> = ({ items }) => {
);
};
export default DealsTable;
export default CardsTable;

View File

@@ -1,11 +1,11 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ActionIcon, Image, Radio } from "@mantine/core";
import { DealSummary } from "../../../../../../../client";
import { usePrefillDealContext } from "../../../../../contexts/PrefillDealContext.tsx";
import { CardSummary } from "../../../../../../../client";
import { usePrefillCardContext } from "../../../../../contexts/PrefillCardContext.tsx";
const useDealsTableColumns = () => {
return useMemo<MRT_ColumnDef<DealSummary>[]>(
const useCardsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardSummary>[]>(
() => [
{
accessorKey: "select",
@@ -13,13 +13,13 @@ const useDealsTableColumns = () => {
size: 5,
enableSorting: false,
Cell: ({ row }) => {
const { selectPrefillDeal, selectedPrefillDeal } = usePrefillDealContext();
const checked = row.original.id === selectedPrefillDeal?.id;
const { selectPrefillCard, selectedPrefillCard } = usePrefillCardContext();
const checked = row.original.id === selectedPrefillCard?.id;
return (
<Radio
checked={checked}
onChange={() => {
selectPrefillDeal(row.original.id);
selectPrefillCard(row.original.id);
}}
/>
);
@@ -69,4 +69,4 @@ const useDealsTableColumns = () => {
);
};
export default useDealsTableColumns;
export default useCardsTableColumns;

View File

@@ -1,12 +1,12 @@
import { FC } from "react";
import { MRT_TableOptions } from "mantine-react-table";
import { DealProductServiceSchema } from "../../../../../../../client";
import { CardProductServiceSchema } from "../../../../../../../client";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import useProductServicesTableColumns from "./columns.tsx";
type Props = {
items: DealProductServiceSchema[];
items: CardProductServiceSchema[];
quantity: number;
};
@@ -23,7 +23,7 @@ const ProductServicesTable: FC<Props> = ({ items, quantity }) => {
enableSorting: false,
enableRowActions: false,
enableBottomToolbar: false,
} as MRT_TableOptions<DealProductServiceSchema>
} as MRT_TableOptions<CardProductServiceSchema>
}
/>
);

View File

@@ -1,11 +1,11 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { DealProductServiceSchema } from "../../../../../../../client";
import { CardProductServiceSchema } from "../../../../../../../client";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../../redux/store.ts";
type Props = {
data: DealProductServiceSchema[];
data: CardProductServiceSchema[];
quantity: number;
};
@@ -17,7 +17,7 @@ const useProductServicesTableColumns = (props: Props) => {
[data, quantity]
);
const hideGuestColumns = ["service.cost"];
return useMemo<MRT_ColumnDef<DealProductServiceSchema>[]>(
return useMemo<MRT_ColumnDef<CardProductServiceSchema>[]>(
() => [
{
accessorKey: "service.name",

View File

@@ -1,15 +1,16 @@
import { useForm } from "@mantine/form";
import { useEffect, useState } from "react";
import { BaseMarketplaceSchema } from "../../../../../client";
import { useDealSummariesFull } from "../../../hooks/useDealSummaries.tsx";
import { useCardSummariesFull } from "../../../hooks/useCardSummaries.tsx";
import isModuleInProject, { Modules } from "../../../../../modules/utils/isModuleInProject.ts";
type State = {
idOrName: string | null;
marketplace: BaseMarketplaceSchema | null;
};
const usePrefillDeal = () => {
const { objects } = useDealSummariesFull();
const usePrefillCard = () => {
const { objects } = useCardSummariesFull();
const form = useForm<State>({
initialValues: {
idOrName: null,
@@ -20,14 +21,16 @@ const usePrefillDeal = () => {
const applyFilters = () => {
let result = objects;
result = result.filter(obj => isModuleInProject(Modules.SERVICES_AND_PRODUCTS, obj.board.project));
if (form.values.idOrName) {
if (isNaN(parseInt(form.values.idOrName))) {
const name: string = form.values.idOrName.toLowerCase();
result = result.filter(
obj => obj.name.toLowerCase().search(name) !== -1,
);
}
else {
} else {
const id = parseInt(form.values.idOrName);
result = result.filter(
obj => obj.id === id,
@@ -49,4 +52,4 @@ const usePrefillDeal = () => {
return { data, form };
};
export default usePrefillDeal;
export default usePrefillCard;

View File

@@ -5,20 +5,20 @@
max-height: 95vh;
}
.deal-container {
.card-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.deal-container-wrapper {
.card-container-wrapper {
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(10);
}
.deal-container-buttons {
.card-container-buttons {
gap: rem(10);
display: flex;
flex-direction: column;

View File

@@ -1,7 +1,7 @@
import { Drawer, rem } from "@mantine/core";
import ExcelDropzone from "../../../../components/ExcelDropzone/ExcelDropzone.tsx";
import styles from "../PrefillDealWithExcelDrawer/PrefillDealsWithExcelDrawer.module.css";
import { usePrefillDealsWithExcelContext } from "../../contexts/PrefillDealsWithExcelContext.tsx";
import styles from "./PrefillCardsWithExcelDrawer.module.css";
import { usePrefillCardsWithExcelContext } from "../../contexts/PrefillDealsWithExcelContext.tsx";
import ProductsPreview from "./components/ProductsPreview.tsx";
import { BoardSchema } from "../../../../client";
@@ -9,22 +9,22 @@ type Props = {
board: BoardSchema | null;
}
const PrefillDealsWithExcelDrawer = ({ board }: Props) => {
const PrefillCardsWithExcelDrawer = ({ board }: Props) => {
const {
prefillWithExcelOpened,
prefillWithExcelOnClose,
barcodeProductsMap,
onDrop,
excelDropzone,
} = usePrefillDealsWithExcelContext();
} = usePrefillCardsWithExcelContext();
const getBody = () => {
if (!board || board.dealStatuses.length === 0) return;
if (!board || board.statuses.length === 0) return;
if (barcodeProductsMap?.size === 0) {
return <ExcelDropzone dropzone={excelDropzone} onDrop={onDrop} />;
}
return <ProductsPreview status={board.dealStatuses[0]}/>;
return <ProductsPreview status={board.statuses[0]}/>;
};
return (
@@ -44,11 +44,11 @@ const PrefillDealsWithExcelDrawer = ({ board }: Props) => {
},
}}
>
<div className={styles["deal-container"]}>
<div className={styles["card-container"]}>
{getBody()}
</div>
</Drawer>
);
};
export default PrefillDealsWithExcelDrawer;
export default PrefillCardsWithExcelDrawer;

View File

@@ -1,9 +1,9 @@
import { usePrefillDealsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { Text, Tooltip } from "@mantine/core";
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react";
const ParsingResultsTooltip = () => {
const { errors } = usePrefillDealsWithExcelContext();
const { errors } = usePrefillCardsWithExcelContext();
const isError = errors.length !== 0;
const errorLines = errors.map((error, i) => <Text key={i}>{error}</Text>);

View File

@@ -1,6 +1,6 @@
import styles from "../PrefillDealsWithExcelDrawer.module.css";
import styles from "../PrefillCardsWithExcelDrawer.module.css";
import ProductsTable from "./ProductsTable.tsx";
import { usePrefillDealsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { Box, Button, Flex, Group, Stack, Title } from "@mantine/core";
import { ProductExcelData } from "../types.tsx";
import BreakdownByCityTable from "./BreakdownByCityTable.tsx";
@@ -13,7 +13,7 @@ type Props = {
}
const ProductsPreview = ({ status }: Props) => {
const { barcodeProductsMap, createDeals, form } = usePrefillDealsWithExcelContext();
const { barcodeProductsMap, createCards, form } = usePrefillCardsWithExcelContext();
const getTitle = (barcode: string, productsData: ProductExcelData) => {
if (productsData.products.length === 1) {
@@ -24,7 +24,7 @@ const ProductsPreview = ({ status }: Props) => {
const getProductsData = () => {
return barcodeProductsMap.entries().map(([barcode, productsData]) => (
<div key={barcode} className={styles["deal-container-wrapper"]}>
<div key={barcode} className={styles["card-container-wrapper"]}>
<Stack>
<Title order={5}>
{getTitle(barcode, productsData)}
@@ -46,7 +46,7 @@ const ProductsPreview = ({ status }: Props) => {
return (
<Stack gap={"md"}>
<Title order={3}>Предпросмотр</Title>
<form onSubmit={form.onSubmit((values) => createDeals(values, status))}>
<form onSubmit={form.onSubmit((values) => createCards(values, status))}>
<ClientSelect
{...form.getInputProps("client")}
inputContainer={(children) => (
@@ -57,7 +57,7 @@ const ProductsPreview = ({ status }: Props) => {
variant="outline"
type="submit"
>
Создать сделки
Создать
</Button>
<ParsingResultsTooltip />
</Group>

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ProductSchema } from "../../../../../client";
import { Radio } from "@mantine/core";
import { usePrefillDealsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { ProductExcelData } from "../types.tsx";
type Props = {
@@ -10,7 +10,7 @@ type Props = {
}
export const useProductsTableColumns = ({ barcode }: Props) => {
const { onProductSelectChange, barcodeProductsMap } = usePrefillDealsWithExcelContext();
const { onProductSelectChange, barcodeProductsMap } = usePrefillCardsWithExcelContext();
const [productData, setProductData] = useState<ProductExcelData>();
useEffect(() => {

View File

@@ -6,6 +6,6 @@ export type ProductExcelData = {
selectedProduct?: ProductSchema;
}
export type DealsWithExcelForm = {
export type CardsWithExcelForm = {
client?: ClientSchema;
}

View File

@@ -0,0 +1,7 @@
.container-wrapper {
flex: 1;
height: 95vh;
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(10);
}

View File

@@ -0,0 +1,87 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { IconHexagons, IconSettings, IconSubtask, IconTags } from "@tabler/icons-react";
import { ReactNode } from "react";
import { motion } from "framer-motion";
import { useProjectsEditorContext } from "../../contexts/ProjectsEditorContext.tsx";
import General from "./tabs/General/General.tsx";
import Attributes from "./tabs/Attributes/Attributes.tsx";
import Modules from "./tabs/Modules/Modules.tsx";
import Tags from "./tabs/Tags/Tags.tsx";
const ProjectEditDrawer = () => {
const { closeProjectsEditor, openedProjectsEditor } = useProjectsEditorContext();
const getTabPanel = (value: string, component: ReactNode) => {
return (
<Tabs.Panel value={value}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<Box
h={"100%"}
w={"100%"}
p={rem(10)}>
{component}
</Box>
</motion.div>
</Tabs.Panel>
);
};
return (
<Drawer
size={"calc(100vw - 150px)"}
position={"right"}
onClose={closeProjectsEditor}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={openedProjectsEditor}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Tabs
defaultValue={"general"}
flex={1}
variant={"outline"}
orientation={"vertical"}
keepMounted={false}>
<Tabs.List>
<Tabs.Tab
value={"general"}
leftSection={<IconSettings />}>
Общее
</Tabs.Tab>
<Tabs.Tab
value={"modules"}
leftSection={<IconHexagons />}>
Модули
</Tabs.Tab>
<Tabs.Tab
value={"attributes"}
leftSection={<IconSubtask />}>
Атрибуты
</Tabs.Tab>
<Tabs.Tab
value={"tags"}
leftSection={<IconTags />}>
Теги
</Tabs.Tab>
</Tabs.List>
{getTabPanel("general", <General />)}
{getTabPanel("attributes", <Attributes />)}
{getTabPanel("modules", <Modules />)}
{getTabPanel("tags", <Tags />)}
</Tabs>
</Drawer>
);
};
export default ProjectEditDrawer;

View File

@@ -0,0 +1,71 @@
import { ProjectService } from "../../../../../../client";
import useAttributesList from "../../../../../../hooks/useAttributesList.tsx";
import { useEffect, useState } from "react";
import { useSet } from "@mantine/hooks";
import useAttributesTableColumns from "./hooks/attributesTableColumns.tsx";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { rem, Stack } from "@mantine/core";
import { BaseTable } from "../../../../../../components/BaseTable/BaseTable.tsx";
import eqSet from "../../utils/eqSet.ts";
import InlineButton from "../../../../../../components/InlineButton/InlineButton.tsx";
import { IconCheck } from "@tabler/icons-react";
import { useProjectsContext } from "../../../../../../contexts/ProjectsContext.tsx";
const Attributes = () => {
const { selectedProject: project, refetchProjects } = useProjectsContext();
const { objects: attributes } = useAttributesList();
const [defaultSelectedAttributes, setDefaultSelectedAttributes] = useState(new Set<number>(project?.attributes.map(m => m.id)));
const selectedAttributes = useSet<number>(project?.attributes.map(a => a.id));
const columns = useAttributesTableColumns({ selectedAttributes });
useEffect(() => {
selectedAttributes.clear();
project?.attributes.forEach(attribute => {
selectedAttributes.add(attribute.id);
});
setDefaultSelectedAttributes(new Set([...selectedAttributes]));
}, [project]);
const onUpdateAttributesClick = () => {
if (!project) return;
ProjectService.updateProjectAttributes({
requestBody: {
projectId: project.id,
attributeIds: selectedAttributes.values().toArray(),
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
refetchProjects();
})
.catch(err => console.log(err));
};
return (
<Stack gap={rem(10)}>
<BaseTable
data={attributes}
columns={columns}
restProps={{
enableSorting: false,
enableColumnActions: false,
enableRowVirtualization: true,
mantineTableContainerProps: { style: { maxHeight: "88vh" } },
}}
/>
{!eqSet(selectedAttributes, defaultSelectedAttributes) && (
<InlineButton onClick={onUpdateAttributesClick}>
<IconCheck />
Сохранить
</InlineButton>
)}
</Stack>
);
};
export default Attributes;

View File

@@ -0,0 +1,117 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { AttributeSchema } from "../../../../../../../client";
import { Box, Center, Checkbox } from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
import { formatDate, formatDateTime } from "../../../../../../../types/utils.ts";
type Props = {
selectedAttributes: Set<number>;
}
const useAttributesTableColumns = ({ selectedAttributes }: Props) => {
return useMemo<MRT_ColumnDef<AttributeSchema>[]>(
() => [
{
header: " ",
Cell: ({ row }) => (
<Center>
<Checkbox
checked={selectedAttributes.has(row.original.id)}
onChange={() => {
if (selectedAttributes.has(row.original.id)) {
selectedAttributes.delete(row.original.id);
} else {
selectedAttributes.add(row.original.id);
}
}}
/>
</Center>
),
size: 5,
},
{
header: "Название",
accessorKey: "label",
size: 150,
},
{
header: "Тип",
accessorKey: "type.name",
size: 120,
},
// {
// header: " ",
// Cell: ({ row }) => {
// const description = row.original.description ? `Описание: ${row.original.description}` : "";
// const info = (
// <Box>
// <Text>Может быть пустым: {row.original.isNullable ? "да" : "нет"}</Text>
// <Text>{defaultValueToStr(row.original.defaultValue, row.original.type.type)}</Text>
// <Text>Синхронизировано в группе: {row.original.isApplicableToGroup ? "да" : "нет"}</Text>
// <Text>{description}</Text>
// </Box>
// );
// return (
// <Tooltip label={info} multiline w={rem(300)}>
// <IconInfoCircle />
// </Tooltip>
// );
// },
// size: 5,
// },
{
header: "Значение по умолчанию",
accessorKey: "defaultValue",
Cell: ({ cell, row }) => {
const value = cell.getValue();
if (value === null) return <>-</>;
const type = row.original.type.type;
if (type === "datetime") {
return formatDateTime(value as string);
}
if (type === "date") {
return formatDate(value as string);
}
if (type === "bool") {
return value ? <IconCheck /> : <IconX />;
}
return <>{value}</>;
},
size: 150,
},
{
header: "Синхронизировано в группе",
accessorKey: "isApplicableToGroup",
Cell: ({ cell }) => cell.getValue() ? (
<IconCheck />
) : (
<IconX />
),
size: 120,
},
{
header: "Может быть пустым",
accessorKey: "isNullable",
Cell: ({ cell }) => cell.getValue() ? (
<IconCheck />
) : (
<IconX />
),
size: 120,
},
{
header: "Описаниие",
accessorKey: "description",
Cell: ({ row }) => <Box>{row.original.description}</Box>,
size: 250,
},
],
[selectedAttributes],
);
};
export default useAttributesTableColumns;

View File

@@ -0,0 +1,114 @@
import { Button, Fieldset, Flex, rem, Stack, Text, TextInput } from "@mantine/core";
import { ProjectService } from "../../../../../../client";
import { useForm } from "@mantine/form";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { isEqual } from "lodash";
import { useProjectsContext } from "../../../../../../contexts/ProjectsContext.tsx";
import { modals } from "@mantine/modals";
import { useProjectsEditorContext } from "../../../../contexts/ProjectsEditorContext.tsx";
type ProjectForm = {
name: string;
}
const General = () => {
const { selectedProject: project, refetchProjects } = useProjectsContext();
const { closeProjectsEditor } = useProjectsEditorContext();
if (!project) return;
const form = useForm<ProjectForm>({
initialValues: project,
validate: {
name: name => !name && "Название проекта не введено",
},
});
const onProjectDelete = () => {
ProjectService.deleteProject({
projectId: project.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
closeProjectsEditor();
refetchProjects();
})
.catch(err => console.log(err));
};
const onDeleteProjectClick = () => {
modals.openConfirmModal({
title: "Удаление проекта",
children: (
<Text size="sm">
Вы уверены что хотите удалить проект "{project.name}"?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => onProjectDelete(),
});
};
const onSubmit = (values: ProjectForm) => {
ProjectService.updateProject({
requestBody: {
project: {
id: project.id,
name: values.name,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchProjects();
})
.catch(err => console.log(err));
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<Fieldset legend={"Общие параметры"}>
<Stack>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
/>
</Stack>
</Fieldset>
<Flex direction={"row-reverse"} gap={rem(10)}>
<Button
variant={"default"}
type={"submit"}
disabled={isEqual(project, form.values)}
>
Сохранить изменения
</Button>
<Button
type={"reset"}
variant={"default"}
disabled={isEqual(project, form.values)}
onClick={() => form.reset()}
>
Отменить изменения
</Button>
<Button
variant={"default"}
onClick={() => onDeleteProjectClick()}
>
Удалить
</Button>
</Flex>
</Stack>
</form>
);
};
export default General;

View File

@@ -1,16 +1,28 @@
import { useMemo } from "react";
import React, { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { BaseProjectSchema, ProjectSchema } from "../../../../../client";
import { TextInput } from "@mantine/core";
import { BaseProjectSchema, ProjectSchema } from "../../../../../../../client";
import { Radio, TextInput } from "@mantine/core";
type Props = {
editingProjects: Map<number, ProjectSchema | BaseProjectSchema>;
selectedProject: ProjectSchema | null;
setSelectedProject: React.Dispatch<React.SetStateAction<ProjectSchema | null>>;
}
const useProjectsTableColumns = ({ editingProjects }: Props) => {
const useProjectsTableColumns = ({ editingProjects, selectedProject, setSelectedProject }: Props) => {
return useMemo<MRT_ColumnDef<ProjectSchema>[]>(
() => [
{
header: " ",
Cell: ({ row }) => (
<Radio
checked={row.original.id === selectedProject?.id}
onChange={() => setSelectedProject(row.original)}
/>
),
size: 10,
},
{
header: "Название",
accessorKey: "name",
@@ -39,7 +51,7 @@ const useProjectsTableColumns = ({ editingProjects }: Props) => {
size: 10,
},
],
[],
[selectedProject],
);
};

View File

@@ -0,0 +1,59 @@
import { ContextModalProps } from "@mantine/modals";
import { Button, Stack, TextInput } from "@mantine/core";
import { BaseProjectSchema, ProjectService } from "../../../../../../../client";
import { useForm } from "@mantine/form";
import { notifications } from "../../../../../../../shared/lib/notifications.ts";
type Props = {
refetchProjects: () => void;
};
const CreateProjectModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const closeModal = () => context.closeContextModal(id);
const form = useForm<BaseProjectSchema>({
initialValues: { name: "" },
validate: {
name: name => !name && "Название не заполнено",
},
});
const onSubmit = (project: BaseProjectSchema) => {
ProjectService.createProject({
requestBody: { project },
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
innerProps.refetchProjects();
closeModal();
})
.catch(err => console.log(err));
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack gap={"md"}>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Stack>
</form>
);
};
export default CreateProjectModal;

View File

@@ -0,0 +1,70 @@
import useModulesList from "./hooks/useModulesList.tsx";
import { useEffect, useState } from "react";
import { useSet } from "@mantine/hooks";
import useModulesTableColumns from "./hooks/modulesTableColumns.tsx";
import { ProjectService } from "../../../../../../client";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { rem, Stack } from "@mantine/core";
import { BaseTable } from "../../../../../../components/BaseTable/BaseTable.tsx";
import eqSet from "../../utils/eqSet.ts";
import InlineButton from "../../../../../../components/InlineButton/InlineButton.tsx";
import { IconCheck } from "@tabler/icons-react";
import { useProjectsContext } from "../../../../../../contexts/ProjectsContext.tsx";
const Modules = () => {
const { selectedProject: project, refetchProjects } = useProjectsContext();
const { objects: modules } = useModulesList();
const [defaultSelectedModules, setDefaultSelectedModules] = useState(
new Set<number>(project?.modules.map(m => m.id)),
);
const selectedModules = useSet<number>();
const columns = useModulesTableColumns({ selectedModules });
useEffect(() => {
selectedModules.clear();
project?.modules.forEach(module => {
selectedModules.add(module.id);
});
setDefaultSelectedModules(new Set([...selectedModules]));
}, [project]);
const updateProjectModules = () => {
if (!project) return;
ProjectService.updateProjectModules({
requestBody: {
projectId: project.id,
moduleIds: selectedModules.values().toArray(),
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
refetchProjects();
})
.catch(err => console.log(err));
};
return (
<Stack gap={rem(10)}>
<BaseTable
data={modules}
columns={columns}
restProps={{
enableSorting: false,
enableColumnActions: false,
}}
/>
{!eqSet(selectedModules, defaultSelectedModules) && (
<InlineButton onClick={updateProjectModules}>
<IconCheck />
Сохранить
</InlineButton>
)}
</Stack>
);
};
export default Modules;

View File

@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ModuleSchema } from "../../../../../../../client";
import { Center, Checkbox } from "@mantine/core";
type Props = {
selectedModules: Set<number>;
}
const useModulesTableColumns = ({ selectedModules }: Props) => {
return useMemo<MRT_ColumnDef<ModuleSchema>[]>(
() => [
{
header: " ",
Cell: ({ row }) => (
<Center>
<Checkbox
checked={selectedModules.has(row.original.id)}
onChange={() => {
if (selectedModules.has(row.original.id)) {
selectedModules.delete(row.original.id);
} else {
selectedModules.add(row.original.id);
}
}}
/>
</Center>
),
size: 1,
},
{
header: "Название",
accessorKey: "label",
size: 10000,
},
],
[selectedModules],
);
};
export default useModulesTableColumns;

View File

@@ -0,0 +1,11 @@
import { ProjectService } from "../../../../../../../client";
import ObjectList from "../../../../../../../hooks/objectList.tsx";
const useModulesList = () =>
ObjectList({
queryFn: ProjectService.getAllModules,
getObjectsFn: response => response.modules,
queryKey: "getAllModules",
});
export default useModulesList;

View File

@@ -0,0 +1,66 @@
import { ActionIcon, Flex, Group, rem, Stack, Tooltip } from "@mantine/core";
import { BaseTable } from "../../../../../../components/BaseTable/BaseTable.tsx";
import tagsTableColumns from "./hooks/tagsTableColumns.tsx";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { CardTagSchema } from "../../../../../../client";
import { MRT_TableOptions } from "mantine-react-table";
import useTags from "./hooks/useTags.tsx";
import InlineButton from "../../../../../../components/InlineButton/InlineButton.tsx";
const Tags = () => {
const columns = tagsTableColumns();
const {
project,
onDeleteClick,
onChangeClick,
onCreateClick,
} = useTags();
return (
<Stack gap={rem(10)}>
<Group>
<InlineButton onClick={onCreateClick}>
<IconPlus />
Создать
</InlineButton>
</Group>
<BaseTable
data={project?.tags}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
renderRowActions: ({ row }) => {
return (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onChangeClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
);
},
} as MRT_TableOptions<CardTagSchema>
}
/>
</Stack>
);
};
export default Tags;

View File

@@ -0,0 +1,18 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardTagSchema } from "../../../../../../../client";
const useTagsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardTagSchema>[]>(
() => [
{
header: "Название",
accessorKey: "name",
size: 1000,
},
],
[],
);
};
export default useTagsTableColumns;

View File

@@ -0,0 +1,109 @@
import {
CancelablePromise,
CardTagSchema,
CardTagService,
CreateTagResponse,
DeleteTagResponse,
UpdateTagResponse,
} from "../../../../../../../client";
import { notifications } from "../../../../../../../shared/lib/notifications.ts";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useProjectsContext } from "../../../../../../../contexts/ProjectsContext.tsx";
import { useCardPageContext } from "../../../../../contexts/CardPageContext.tsx";
const useTags = () => {
const { selectedProject: project, refetchProjects } = useProjectsContext();
const { refetchCards } = useCardPageContext();
const processResponse = (
response: CancelablePromise<DeleteTagResponse | UpdateTagResponse | CreateTagResponse>,
) => {
response
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchProjects();
refetchCards();
})
.catch(err => console.log(err));
};
const onDelete = (tag: CardTagSchema) => {
const response = CardTagService.deleteTag({
cardTagId: tag.id,
});
processResponse(response);
};
const onDeleteClick = (tag: CardTagSchema) => {
modals.openConfirmModal({
title: "Удаление тега",
children: (
<Text size="sm">
Вы уверены что хотите удалить тег {tag.name}
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => onDelete(tag),
});
};
const onChange = (tag: CardTagSchema) => {
const response = CardTagService.updateTag({
requestBody: { tag },
});
processResponse(response);
};
const onChangeClick = (tag: CardTagSchema) => {
modals.openContextModal({
modal: "cardTagModal",
innerProps: {
element: tag,
onChange,
},
withCloseButton: false,
});
};
const onCreate = (tag: CardTagSchema) => {
if (!project) return;
const response = CardTagService.createTag({
requestBody: {
tag: {
name: tag.name,
projectId: project.id,
}
},
});
processResponse(response);
};
const onCreateClick = () => {
modals.openContextModal({
modal: "cardTagModal",
innerProps: {
onCreate,
},
withCloseButton: false,
});
};
return {
project,
onDeleteClick,
onChangeClick,
onCreateClick,
};
};
export default useTags;

View File

@@ -0,0 +1,47 @@
import { ContextModalProps } from "@mantine/modals";
import BaseFormModal, {
CreateEditFormProps,
} from "../../../../../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import { CardTagSchema } from "../../../../../../../client";
import { useForm } from "@mantine/form";
import { TextInput } from "@mantine/core";
type Props = CreateEditFormProps<CardTagSchema>;
const CardTagModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "element" in innerProps;
const initialValue: Partial<CardTagSchema> = isEditing
? innerProps.element
: {
name: "",
};
const form = useForm<Partial<CardTagSchema>>({
initialValues: initialValue,
validate: {
name: name => !name && "Необходимо указать название тега",
},
});
return (
<BaseFormModal
form={form}
closeOnSubmit
onClose={() => context.closeContextModal(id)}
{...innerProps}>
<BaseFormModal.Body>
<TextInput
label={"Название"}
placeholder={"Введите название тега"}
{...form.getInputProps("name")}
/>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default CardTagModal;

View File

@@ -0,0 +1,23 @@
import { formatDate, formatDateTime } from "../../../../../types/utils.ts";
const defaultValueToStr = (defaultValue: string | null | number | boolean, type: string): string => {
if (defaultValue === null) {
return "Значения по умолчанию нет";
}
let strValue;
const prefix = "Значение по умолчанию: ";
if (type === "bool") {
strValue = defaultValue ? "true" : "false";
} else if (type === "datetime") {
strValue = formatDateTime(defaultValue as string);
} else if (type === "date") {
strValue = formatDate(defaultValue as string);
} else {
strValue = String(defaultValue);
}
return prefix + strValue;
};
export default defaultValueToStr;

View File

@@ -0,0 +1,6 @@
const eqSet = (firstSet: Set<number>, secondSet: Set<number>) =>
firstSet.size === secondSet.size &&
[...firstSet].every((x) => secondSet.has(x));
export default eqSet;

View File

@@ -0,0 +1,25 @@
const translitMap: Record<string, string> = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ъ': '',
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
};
const transliterate = (text: string): string => {
return text.toLowerCase().split('').map(char => translitMap[char] || char).join('');
};
const toSnakeCase = (text: string): string => {
return text
.toLowerCase()
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/[^a-z_0-9]/g, '') // Remove non-English letters and special chars
.replace(/__+/g, '_') // Replace multiple underscores with single one
.replace(/^_+|_+$/g, ''); // Trim leading/trailing underscores
};
export const convertRussianToSnakeCase = (input: string): string => {
const transliterated = transliterate(input);
return toSnakeCase(transliterated);
};

View File

@@ -1,7 +1,8 @@
enum DragState {
DRAG_ENDED,
DRAG_DEAL,
DRAG_CARD,
DRAG_STATUS,
DRAG_BOARD,
}
export default DragState;

View File

@@ -1,18 +1,17 @@
import { useEffect, useState } from "react";
import { BoardSchema, BoardService } from "../../../client";
import { useProjectsContext } from "../../../contexts/ProjectsContext.tsx";
type Props = {
projectId?: number;
}
const useBoards = ({ projectId }: Props) => {
const useBoards = () => {
const { selectedProject } = useProjectsContext();
const [boards, setBoards] = useState<BoardSchema[]>([]);
const refetchBoards = () => {
if (!projectId) return;
if (!selectedProject) return;
BoardService.getBoards({
projectId,
projectId: selectedProject.id,
})
.then(data => {
setBoards(data.boards);
@@ -22,7 +21,7 @@ const useBoards = ({ projectId }: Props) => {
useEffect(() => {
refetchBoards();
}, [projectId]);
}, [selectedProject]);
return {
boards,

View File

@@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { DealService } from "../../../client";
import { CardService } from "../../../client";
import ObjectList from "../../../hooks/objectList.tsx";
export const useDealSummaries = () => {
export const useCardSummaries = () => {
const { data: summariesRaw = [], refetch } = useQuery({
queryKey: ["getDealSummaries"],
queryFn: () => DealService.getDealSummaries({ full: false }),
queryKey: ["getCardSummaries"],
queryFn: () => CardService.getCardSummaries({ full: false }),
select: data => data.summaries || [], // Трансформируем полученные данные
});
@@ -14,9 +14,9 @@ export const useDealSummaries = () => {
return { summariesRaw, refetch };
};
export const useDealSummariesFull = () =>
export const useCardSummariesFull = () =>
ObjectList({
queryFn: () => DealService.getDealSummaries({ full: true }),
queryKey: "getDealSummariesFull",
queryFn: () => CardService.getCardSummaries({ full: true }),
queryKey: "getCardSummariesFull",
getObjectsFn: response => response.summaries,
});

View File

@@ -1,37 +1,30 @@
import { useDealSummariesFull } from "./useDealSummaries.tsx";
import { useCardSummariesFull } from "./useCardSummaries.tsx";
import { useForm } from "@mantine/form";
import { useEffect, useState } from "react";
import { BaseMarketplaceSchema, BoardSchema, ClientSchema, ProjectSchema, StatusSchema } from "../../../client";
type Props = {
projects: ProjectSchema[];
}
export type DealsPageState = {
export type CardsPageState = {
id: number | null;
marketplace: BaseMarketplaceSchema | null;
client: ClientSchema | null;
project: ProjectSchema | null;
projectForTable: ProjectSchema | null;
board: BoardSchema | null;
dealStatus: StatusSchema | null;
status: StatusSchema | null;
};
const useDealsPageState = ({ projects }: Props) => {
const { objects } = useDealSummariesFull();
const useCardsPageState = () => {
const { objects } = useCardSummariesFull();
const form = useForm<DealsPageState>({
const form = useForm<CardsPageState>({
initialValues: {
project: null,
id: null,
marketplace: null,
client: null,
projectForTable: null,
board: null,
dealStatus: null,
status: null,
},
});
@@ -51,7 +44,7 @@ const useDealsPageState = ({ projects }: Props) => {
}
if (form.values.projectForTable) {
result = result.filter(
obj => obj.board.projectId === form.values.project?.id,
obj => obj.board.projectId === form.values.projectForTable?.id,
);
if (form.values.board) {
@@ -59,9 +52,9 @@ const useDealsPageState = ({ projects }: Props) => {
obj => obj.board.id === form.values.board?.id,
);
if (form.values.dealStatus) {
if (form.values.status) {
result = result.filter(
obj => obj.status.id === form.values.dealStatus?.id,
obj => obj.status.id === form.values.status?.id,
);
}
}
@@ -78,13 +71,7 @@ const useDealsPageState = ({ projects }: Props) => {
applyFilters();
}, [form.values, objects]);
useEffect(() => {
if (projects.length > 0 && form.values.project === null) {
form.setFieldValue("project", projects[0]);
}
}, [projects]);
return { data, form };
};
export default useDealsPageState;
export default useCardsPageState;

View File

@@ -0,0 +1 @@
export { CardsPage } from "./ui/CardsPage.tsx";

View File

@@ -1,9 +1,9 @@
import { ProjectSchema } from "../../../client";
import { Flex, Modal, NumberInput, rem } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { DealsPageState } from "../hooks/useDealsPageState.tsx";
import { CardsPageState } from "../hooks/useCardsPageState.tsx";
import ObjectSelect from "../../../components/ObjectSelect/ObjectSelect.tsx";
import DealStatusSelect from "../../../components/DealStatusSelect/DealStatusSelect.tsx";
import CardStatusSelect from "../../../components/CardStatusSelect/CardStatusSelect.tsx";
import BaseMarketplaceSelect from "../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import ClientSelectNew from "../../../components/Selects/ClientSelectNew/ClientSelectNew.tsx";
import { useDisclosure } from "@mantine/hooks";
@@ -12,11 +12,11 @@ import { IconFilter } from "@tabler/icons-react";
import BoardSelect from "../../../components/BoardSelect/BoardSelect.tsx";
type Props = {
form: UseFormReturnType<DealsPageState>;
form: UseFormReturnType<CardsPageState>;
projects: ProjectSchema[];
};
const DealsTableFiltersModal = ({ form, projects }: Props) => {
const CardsTableFiltersModal = ({ form, projects }: Props) => {
const [opened, { open, close }] = useDisclosure();
return (
@@ -25,7 +25,7 @@ const DealsTableFiltersModal = ({ form, projects }: Props) => {
<IconFilter />
Фильтры
</InlineButton>
<Modal title={"Фильтры для сделок"} opened={opened} onClose={close}>
<Modal title={"Фильтры"} opened={opened} onClose={close}>
<Flex
direction={"column"}
gap={rem(10)}
@@ -49,9 +49,9 @@ const DealsTableFiltersModal = ({ form, projects }: Props) => {
{...form.getInputProps("board")}
clearable
/>
<DealStatusSelect
<CardStatusSelect
board={form.values.board}
{...form.getInputProps("dealStatus")}
{...form.getInputProps("status")}
clearable
/>
<BaseMarketplaceSelect
@@ -75,4 +75,4 @@ const DealsTableFiltersModal = ({ form, projects }: Props) => {
);
};
export default DealsTableFiltersModal;
export default CardsTableFiltersModal;

View File

@@ -1,4 +1,4 @@
import { DealProductSchema } from "../../../client";
import { CardProductSchema } from "../../../client";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, rem } from "@mantine/core";
import { useState } from "react";
@@ -6,36 +6,36 @@ import ObjectMultiSelect from "../../../components/ObjectMultiSelect/ObjectMulti
import { notifications } from "../../../shared/lib/notifications.ts";
type Props = {
dealProducts: DealProductSchema[];
dealProduct: DealProductSchema;
cardProducts: CardProductSchema[];
cardProduct: CardProductSchema;
onSelect: (
sourceProduct: DealProductSchema,
destinationProducts: DealProductSchema[]
sourceProduct: CardProductSchema,
destinationProducts: CardProductSchema[]
) => void;
};
const SelectDealProductsModal = ({
const SelectCardProductsModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const [dealProducts, setDealProducts] = useState<DealProductSchema[]>([]);
const [cardProducts, setCardProducts] = useState<CardProductSchema[]>([]);
const onSelectClick = () => {
if (!dealProducts) {
if (!cardProducts) {
notifications.error({
message:
"Выберите товары на которые необходимо продублировать услуги",
});
return;
}
innerProps.onSelect(innerProps.dealProduct, dealProducts);
innerProps.onSelect(innerProps.cardProduct, cardProducts);
context.closeContextModal(id);
};
const onDuplicateAllClick = () => {
innerProps.onSelect(
innerProps.dealProduct,
innerProps.dealProducts.filter(
item => item !== innerProps.dealProduct
innerProps.cardProduct,
innerProps.cardProducts.filter(
item => item !== innerProps.cardProduct
)
);
context.closeContextModal(id);
@@ -45,18 +45,18 @@ const SelectDealProductsModal = ({
direction={"column"}
gap={rem(10)}>
<Flex>
<ObjectMultiSelect<DealProductSchema>
<ObjectMultiSelect<CardProductSchema>
w={"100%"}
label={"Товары"}
placeholder={
"Выберите товары на которые нужно продублировать услуги"
}
onChange={setDealProducts}
value={dealProducts}
data={innerProps.dealProducts}
onChange={setCardProducts}
value={cardProducts}
data={innerProps.cardProducts}
getLabelFn={item => item.product.name}
getValueFn={item => item.product.id.toString()}
filterBy={item => item !== innerProps.dealProduct}
filterBy={item => item !== innerProps.cardProduct}
/>
</Flex>
<Flex
@@ -80,4 +80,4 @@ const SelectDealProductsModal = ({
);
};
export default SelectDealProductsModal;
export default SelectCardProductsModal;

View File

@@ -21,6 +21,7 @@
align-items: stretch;
text-align: center;
flex-direction: column;
height: 100%;
}
.delete {

View File

@@ -0,0 +1,101 @@
import { FC, useState } from "react";
import { useCardSummaries } from "../hooks/useCardSummaries.tsx";
import PageBlock from "../../../components/PageBlock/PageBlock.tsx";
import CardEditDrawer from "../drawers/CardEditDrawer/CardEditDrawer.tsx";
import { CardPageContextProvider } from "../contexts/CardPageContext.tsx";
import { rem } from "@mantine/core";
import useCardsPageState from "../hooks/useCardsPageState.tsx";
import CardsTable from "../components/CardsTable/CardsTable.tsx";
import { motion } from "framer-motion";
import CardPrefillDrawer from "../drawers/CardPrefillDrawer/CardPrefillDrawer.tsx";
import { PrefillCardContextProvider } from "../contexts/PrefillCardContext.tsx";
import { useParams } from "@tanstack/react-router";
import { PrefillCardsWithExcelContextProvider } from "../contexts/PrefillDealsWithExcelContext.tsx";
import DisplayMode from "../enums/DisplayMode.ts";
import CardsPageHeader from "../components/CardsPageHeader/CardsPageHeader.tsx";
import { ProjectsEditorContextProvider } from "../contexts/ProjectsEditorContext.tsx";
import ProjectEditDrawer from "../drawers/ProjectEditDrawer/ProjectEditDrawer.tsx";
import Boards from "../../../components/Dnd/Boards/Boards/Boards.tsx";
import { DndContextProvider } from "../contexts/DndContext.tsx";
export const CardsPage: FC = () => {
const { data, form } = useCardsPageState();
const { dealId } = useParams({ strict: false });
const { summariesRaw, refetch: refetchSummaries } = useCardSummaries();
const [displayMode, setDisplayMode] = useState<DisplayMode>(DisplayMode.BOARD);
const tableBody = (
<CardsTable items={data} />
);
const boardsBody = (
<DndContextProvider
summariesRaw={summariesRaw}
refetchSummaries={refetchSummaries}
>
<Boards />
</DndContextProvider>
);
const getBody = () => {
return (
<motion.div
key={displayMode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
{displayMode === DisplayMode.TABLE
? tableBody
: boardsBody
}
</motion.div>
);
};
return (
<PageBlock
fullHeight
style={{
gap: rem(10),
display: "flex",
flexDirection: "column",
backgroundColor: "transparent",
boxShadow: "none",
padding: 0,
}}
>
<ProjectsEditorContextProvider>
<CardPageContextProvider
defaultCardId={(dealId && parseInt(dealId)) || undefined}
refetchCards={async () => {
await refetchSummaries();
}}
>
<PrefillCardContextProvider>
<PrefillCardsWithExcelContextProvider>
<CardsPageHeader
form={form}
displayMode={displayMode}
setDisplayMode={setDisplayMode}
/>
<PageBlock
style={{
display: "flex",
flexDirection: "column",
flex: 1,
height: "100%",
}}
>
{getBody()}
</PageBlock>
<CardEditDrawer />
<ProjectEditDrawer />
<CardPrefillDrawer />
</PrefillCardsWithExcelContextProvider>
</PrefillCardContextProvider>
</CardPageContextProvider>
</ProjectsEditorContextProvider>
</PageBlock>
);
};

View File

@@ -1 +0,0 @@
export { DealPage } from "./ui/DealPage";

View File

@@ -1,30 +0,0 @@
import { useParams } from "@tanstack/react-router";
import { DealPageContextProvider, useDealPageContext } from "../../DealsPage/contexts/DealPageContext.tsx";
import ProductAndServiceTab from "../../DealsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import { FC, useEffect } from "react";
import { DealService } from "../../../client";
export type Props = {
dealId: number;
};
const DealPageContent: FC<Props> = ({ dealId }) => {
const { setSelectedDeal } = useDealPageContext();
useEffect(() => {
DealService.getDealById({ dealId }).then(deal => {
setSelectedDeal(deal);
});
}, []);
return <ProductAndServiceTab />;
};
const DealPageWrapper: FC<{ children: React.ReactNode }> = ({ children }) => {
return <DealPageContextProvider refetchDeals={async () => {
}}>{children}</DealPageContextProvider>;
};
export const DealPage = () => {
const { dealId } = useParams({ strict: false });
return (
<DealPageWrapper>
<DealPageContent dealId={parseInt(dealId || "-1")} />
</DealPageWrapper>
);
};

View File

@@ -1,132 +0,0 @@
import {
ActionIcon,
Button,
ComboboxItem,
ComboboxItemGroup,
Flex,
Input,
OptionsFilter,
rem,
} from "@mantine/core";
import { BaseFormInputProps } from "../../../../types/utils.ts";
import { DealProductServiceSchema, ServiceSchema } from "../../../../client";
import { FC, useEffect, useState } from "react";
import ServiceWithPriceInput from "../../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { isNumber } from "lodash";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { IconTrash } from "@tabler/icons-react";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type RestProps = {
quantity: number;
};
type Props = BaseFormInputProps<DealProductServiceSchema[]> & RestProps;
const DealProductServiceTable: FC<Props> = (props: Props) => {
const { value, onChange, quantity, error } = props;
const authState = useSelector((state: RootState) => state.auth);
const [innerValue, setInnerValue] = useState<
Partial<DealProductServiceSchema>[]
>(value || []);
const onServiceChange = (idx: number, value: ServiceSchema) => {
setInnerValue(oldValue =>
oldValue.map((item, i) =>
i === idx ? { ...item, service: value } : item
)
);
};
const onQuantityChange = (idx: number, value: string | number) => {
if (!isNumber(value)) return;
setInnerValue(oldValue =>
oldValue.map((item, i) =>
i === idx ? { ...item, price: value } : item
)
);
};
const onCreate = () => {
if (innerValue.length > 0 && !innerValue.at(-1)?.service) {
notifications.error({ message: "Заполните последнюю услугу" });
return;
}
setInnerValue(prevState => [
...prevState,
{ service: undefined, quantity: 1 },
]);
};
const onDelete = (idx: number) => {
setInnerValue(oldValue => oldValue.filter((_, i) => i !== idx));
};
const serviceOptionsFilter = ({
options,
}: {
options: ComboboxItemGroup[];
}) => {
const productServiceIds = innerValue.map(
service => service.service?.id
);
return (options as ComboboxItemGroup[]).map(({ items, group }) => {
return {
group,
items: items.filter(
item =>
!productServiceIds.includes(
parseInt((item as ComboboxItem).value)
)
),
};
});
};
useEffect(() => {
onChange(innerValue as DealProductServiceSchema[]);
}, [innerValue]);
return (
<Input.Wrapper error={error}>
<Flex
direction={"column"}
gap={rem(10)}>
{innerValue.map((service, idx) => (
<Flex
key={service.service?.name || idx}
direction={"row"}
gap={rem(10)}
align={"center"}
justify={"stretch"}>
<ActionIcon
onClick={() => onDelete(idx)}
variant={"default"}>
<IconTrash />
</ActionIcon>
<ServiceWithPriceInput
serviceProps={{
onChange: event => onServiceChange(idx, event),
value: service.service,
placeholder: "Выберите услугу",
style: { width: "100%" },
filter: serviceOptionsFilter as OptionsFilter,
}}
priceProps={{
onChange: event => onQuantityChange(idx, event),
value: service.price,
placeholder: "Введите стоимость",
hideControls: true,
style: { width: "100%" },
suffix: "₽",
disabled: authState.isGuest,
}}
containerProps={{ w: "100%" }}
quantity={quantity}
/>
</Flex>
))}
<Button
onClick={onCreate}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
</Input.Wrapper>
);
};
export default DealProductServiceTable;

View File

@@ -1,168 +0,0 @@
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import useDealProductsTableColumns from "./columns.tsx";
import { FC } from "react";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { DealProductSchema, ProductService } from "../../../../client";
import { ActionIcon, Button, Flex, rem, Tooltip } from "@mantine/core";
import { MRT_TableOptions } from "mantine-react-table";
import { modals } from "@mantine/modals";
import { IconBarcode, IconEdit, IconTrash } from "@tabler/icons-react";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { CreateProductRequest } from "../../../ProductsPage/types.ts";
type RestProps = {
clientId: number;
onMultipleDelete?: (items: DealProductSchema[]) => void;
};
type Props = CRUDTableProps<DealProductSchema> & RestProps;
const DealProductsTable: FC<Props> = (props: Props) => {
const { items, clientId, onChange, onCreate, onDelete, onMultipleDelete } =
props;
const columns = useDealProductsTableColumns({
onChange: (product, quantity) => {
if (!onChange) return;
if (quantity <= 0 && onDelete) {
onDelete(product);
return;
}
onChange({ ...product, quantity });
},
data: items,
});
const onCreateClick = () => {
if (!onCreate) return;
modals.openContextModal({
modal: "addDealProduct",
title: "Добавление товара",
innerProps: {
onCreate: product => onCreate(product as DealProductSchema),
clientId,
},
size: "lg",
});
};
const onPrintBarcodeClick = (product: DealProductSchema) => {
modals.openContextModal({
modal: "printBarcode",
title: "Печать штрихкода",
withCloseButton: true,
innerProps: {
productId: product.product.id,
defaultQuantity: product.quantity,
},
});
};
const onCreateProduct = (newProduct: CreateProductRequest) => {
ProductService.createProduct({
requestBody: newProduct,
}).then(({ ok, message }) => {
notifications.guess(ok, { message: message });
});
};
const onCreateProductClick = () => {
modals.openContextModal({
modal: "createProduct",
title: "Создание товара",
withCloseButton: false,
innerProps: {
clientId: clientId,
onCreate: onCreateProduct,
},
});
};
const onEditClick = (product: DealProductSchema) => {
if (!onChange) return;
modals.openContextModal({
modal: "addDealProduct",
title: "Создание товара",
withCloseButton: false,
innerProps: {
clientId: clientId,
element: product,
onChange: onChange,
},
size: "lg",
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableBottomToolbar: true,
enableRowActions: true,
enableRowSelection: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() =>
onDelete && onDelete(row.original)
}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Печать штрихкода">
<ActionIcon
onClick={() =>
onPrintBarcodeClick(row.original)
}
variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
renderBottomToolbar: ({ table }) => (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
{onMultipleDelete &&
table.getSelectedRowModel().rows.length > 0 && (
<Button
onClick={() => {
onMultipleDelete(
table
.getSelectedRowModel()
.rows.map(
row => row.original
)
);
}}
variant={"filled"}
color={"red"}>
Удалить выбранные
</Button>
)}
<Button
variant={"default"}
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить товар в сделку
</Button>
</Flex>
),
} as MRT_TableOptions<DealProductSchema>
}
/>
);
};
export default DealProductsTable;

View File

@@ -1,107 +0,0 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { DealProductSchema } from "../../../../client";
import { List } from "@mantine/core";
type Props = {
onChange: (product: DealProductSchema, quantity: number) => void;
data: DealProductSchema[];
};
const useDealProductsTableColumns = (props: Props) => {
const { onChange, data } = props;
const totalQuantity = useMemo(
() => data.reduce((acc, row) => acc + row.quantity, 0),
[data]
);
const totalPrice = useMemo(
() =>
data.reduce(
(totalAcc, row) =>
totalAcc +
row.services.reduce(
(singleAcc, service) =>
singleAcc + service.price * row.quantity,
0
),
0
),
[data]
);
return useMemo<MRT_ColumnDef<DealProductSchema>[]>(
() => [
{
accessorKey: "product.article",
header: "Артикул",
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "product.name",
header: "Название",
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "product.barcodes",
header: "Штрихкоды",
Cell: ({ cell }) => {
return (
<List size={"sm"}>
{cell
.getValue<string[]>()
?.map(barcode => (
<List.Item key={barcode}>
{barcode}
</List.Item>
))}
</List>
);
},
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "quantity",
header: "Количество",
enableSorting: false,
enableColumnActions: false,
Footer: <>Всего товаров: {totalQuantity} </>,
},
{
header: "Услуги",
Cell: ({ row }) => (
<List size={"sm"}>
{row.original.services
.map(
service =>
`${service.service.name} (${service.price}₽ за шт)`
)
.map(serviceText => (
<List.Item key={serviceText}>
{serviceText}
</List.Item>
))}
</List>
),
enableColumnActions: false,
},
{
header: "Итоговая стоимость услуг",
Cell: ({ row }) => (
<>
{row.original.services.reduce(
(acc, service) =>
acc + row.original.quantity * service.price,
0
)}
</>
),
enableColumnActions: false,
Footer: <>Всего стоимость услуг: {totalPrice}</>,
},
],
[onChange, data]
);
};
export default useDealProductsTableColumns;

View File

@@ -1,129 +0,0 @@
import { FC } from "react";
import { useDealServicesTableColumns } from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { DealServiceSchema } from "../../../../client";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { MRT_TableOptions } from "mantine-react-table";
import { ActionIcon, Button, Flex, rem, Tooltip } from "@mantine/core";
import { openContextModal } from "@mantine/modals";
import { IconEdit, IconTrash } from "@tabler/icons-react";
type RestProps = {
onMultipleDelete?: (items: DealServiceSchema[]) => void;
};
type Props = CRUDTableProps<DealServiceSchema> & RestProps;
const DealServicesTable: FC<Props> = ({
items,
onChange,
onDelete,
onCreate,
onSelectionChange,
onMultipleDelete,
tableRef,
}) => {
const serviceIds = items.map(item => item.service.id);
const columns = useDealServicesTableColumns({
data: items,
});
const onCreateClick = () => {
if (!onCreate) return;
openContextModal({
title: "Добавление услуги",
modal: "addDealService",
innerProps: {
onCreate: event => onCreate(event as DealServiceSchema),
serviceIds,
},
});
};
const onEditClick = (service: DealServiceSchema) => {
if (!onChange) return;
openContextModal({
title: "Добавление услуги",
modal: "addDealService",
innerProps: {
element: service,
onChange,
serviceIds,
},
});
};
return (
<>
<BaseTable
ref={tableRef}
data={items}
columns={columns}
onSelectionChange={onSelectionChange}
restProps={
{
enableGrouping: true,
initialState: { grouping: ["service.category"] },
enableColumnActions: false,
enableSorting: false,
enableBottomToolbar: true,
enableRowActions: true,
enableRowSelection: true,
renderBottomToolbar: ({ table }) => (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
{onMultipleDelete &&
table.getSelectedRowModel().rows.length >
0 && (
<Button
onClick={() => {
onMultipleDelete(
table
.getSelectedRowModel()
.rows.map(
row => row.original
)
);
}}
variant={"filled"}
color={"red"}>
Удалить выбранные
</Button>
)}
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
),
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => {
if (onDelete)
onDelete(row.original);
}}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => {
onEditClick(row.original);
}}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<DealServiceSchema>
}
/>
</>
);
};
export default DealServicesTable;

View File

@@ -1,63 +0,0 @@
import { MRT_ColumnDef } from "mantine-react-table";
import { useMemo } from "react";
import { DealServiceSchema } from "../../../../client";
type Props = {
data: DealServiceSchema[];
};
export const useDealServicesTableColumns = (props: Props) => {
const { data } = props;
const totalPrice = useMemo(
() => data.reduce((acc, row) => acc + row.quantity * row.price, 0),
[data]
);
return useMemo<MRT_ColumnDef<DealServiceSchema>[]>(
() => [
{
accessorKey: "service.category",
header: "Категория",
accessorFn: row => row.service.category.name,
},
{
enableGrouping: false,
accessorKey: "service.name",
header: "Услуга",
},
{
enableGrouping: false,
accessorKey: "price",
header: "Цена",
},
{
enableGrouping: false,
accessorKey: "quantity",
header: "Количество",
},
{
enableGrouping: false,
header: "Сумма",
Cell: ({ row }) => {
return row.original.quantity * row.original.price;
},
aggregationFn: "sum",
AggregatedCell: ({ cell }) => {
return (
<>
Итоговая сумма по категории:{" "}
{cell.row.subRows?.reduce(
(acc, row) =>
acc +
row.original.quantity * row.original.price,
0
)}
</>
);
},
Footer: <>Итоговая сумма по услугам: {totalPrice}</>,
},
],
[]
);
};

View File

@@ -1,74 +0,0 @@
import { createContext, FC, useContext, useEffect, useState } from "react";
import { DealSchema, DealService } from "../../../client";
type DealPageContextState = {
selectedDeal?: DealSchema;
setSelectedDeal: (deal: DealSchema | undefined) => void;
refetchDeals: () => Promise<void>;
refetchDeal: () => void;
};
const DealPageContext = createContext<DealPageContextState | undefined>(
undefined,
);
type DealPageContextStateProps = {
refetchDeals: () => Promise<void>;
defaultDealId?: number;
}
const useDealPageContextState = (props: DealPageContextStateProps) => {
const { refetchDeals, defaultDealId } = props;
const [selectedDeal, setSelectedDeal] = useState<DealSchema | undefined>(
undefined,
);
const refetchDeal = () => {
const dealId = selectedDeal?.id ?? defaultDealId;
if (!dealId) return;
DealService.getDealById({ dealId }).then(deal => {
setSelectedDeal(deal);
});
};
useEffect(() => {
refetchDeal();
}, []);
return {
selectedDeal,
setSelectedDeal,
refetchDeals,
refetchDeal,
};
};
type DealPageContextProviderProps = {
children: React.ReactNode;
refetchDeals: () => Promise<void>;
defaultDealId?: number;
};
export const DealPageContextProvider: FC<DealPageContextProviderProps> = ({
children,
refetchDeals,
defaultDealId,
}) => {
const state = useDealPageContextState({ refetchDeals, defaultDealId });
return (
<DealPageContext.Provider value={state}>
{children}
</DealPageContext.Provider>
);
};
export const useDealPageContext = () => {
const context = useContext(DealPageContext);
if (!context) {
throw new Error(
"useDealPageContext must be used within a DealPageContextProvider",
);
}
return context;
};

View File

@@ -1,71 +0,0 @@
import { createContext, Dispatch, FC, SetStateAction, useContext, useState } from "react";
import { DealSchema, DealService } from "../../../client";
import { useDisclosure } from "@mantine/hooks";
type PrefillDealContextState = {
prefillOpened: boolean;
prefillOnClose: () => void;
prefillOnOpen: () => void;
selectedPrefillDeal?: DealSchema;
selectPrefillDeal: (dealId: number) => void;
prefillDeal?: DealSchema;
setPrefillDeal: Dispatch<SetStateAction<DealSchema | undefined>>;
};
const PrefillDealContext = createContext<PrefillDealContextState | undefined>(
undefined
);
const usePrefillDealContextState = () => {
const [selectedPrefillDeal, setSelectedPrefillDeal] = useState<DealSchema | undefined>(
undefined,
);
const [prefillDeal, setPrefillDeal] = useState<DealSchema | undefined>(
undefined,
);
const [prefillOpened, { open, close }] = useDisclosure(false);
const prefillOnClose = close;
const prefillOnOpen = open;
const selectPrefillDeal = (dealId: number) => {
DealService.getDealById({ dealId }).then(deal => {
setSelectedPrefillDeal(deal);
});
}
return {
prefillOpened,
prefillOnClose,
prefillOnOpen,
selectedPrefillDeal,
selectPrefillDeal,
prefillDeal,
setPrefillDeal,
};
};
type PrefillDealContextProviderProps = {
children: React.ReactNode;
};
export const PrefillDealContextProvider: FC<PrefillDealContextProviderProps> = ({
children,
}) => {
const state = usePrefillDealContextState();
return (
<PrefillDealContext.Provider value={state}>
{children}
</PrefillDealContext.Provider>
);
};
export const usePrefillDealContext = () => {
const context = useContext(PrefillDealContext);
if (!context) {
throw new Error(
"usePrefillDealContext must be used within a PrefillDealContextProvider"
);
}
return context;
};

View File

@@ -1,126 +0,0 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { FC, ReactNode, useEffect } from "react";
import { useDealPageContext } from "../../contexts/DealPageContext.tsx";
import { IconBox, IconCalendarUser, IconCubeSend, IconSettings, IconUser, IconUsersGroup } from "@tabler/icons-react";
import DealStatusChangeTable from "../../components/DealStatusChangeTable/DealStatusChangeTable.tsx";
import DealEditDrawerGeneralTab from "./tabs/DealEditDrawerGeneralTab.tsx";
import { useQueryClient } from "@tanstack/react-query";
import ProductAndServiceTab from "../../tabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import { motion } from "framer-motion";
import ShippingTab from "../../tabs/ShippingTab/ShippingTab.tsx";
import EmployeesTab from "../../tabs/EmployeesTab/EmployeesTab.tsx";
import ClientTab from "./tabs/ClientTab.tsx";
const useDealStatusChangeState = () => {
const { selectedDeal } = useDealPageContext();
return {
statusHistory: selectedDeal?.statusHistory || [],
};
};
const DealEditDrawerStatusChangeTable = () => {
const { statusHistory } = useDealStatusChangeState();
return <DealStatusChangeTable items={statusHistory} />;
};
const useDealEditDrawerState = () => {
const { selectedDeal, setSelectedDeal } = useDealPageContext();
return {
isVisible: selectedDeal !== undefined,
onClose: () => setSelectedDeal(undefined),
};
};
const DealEditDrawer: FC = () => {
const { isVisible, onClose } = useDealEditDrawerState();
const queryClient = useQueryClient();
useEffect(() => {
if (isVisible) return;
queryClient.invalidateQueries({ queryKey: ["getDealSummaries"] });
}, [isVisible]);
const getTabPanel = (value: string, component: ReactNode) => {
return (
<Tabs.Panel value={value}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<Box
h={"100%"}
w={"100%"}
p={rem(10)}>
{component}
</Box>
</motion.div>
</Tabs.Panel>
);
};
return (
<Drawer
size={"calc(100vw - 150px)"}
position={"right"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isVisible}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Tabs
defaultValue={"general"}
flex={1}
variant={"outline"}
orientation={"vertical"}
keepMounted={false}>
<Tabs.List>
<Tabs.Tab
value={"general"}
leftSection={<IconSettings />}>
Общее
</Tabs.Tab>
<Tabs.Tab
value={"client"}
leftSection={<IconUser />}>
Клиент
</Tabs.Tab>
<Tabs.Tab
value={"history"}
leftSection={<IconCalendarUser />}>
История
</Tabs.Tab>
<Tabs.Tab
value={"servicesAndProducts"}
leftSection={<IconBox />}>
Товары и услуги
</Tabs.Tab>
<Tabs.Tab
value={"shipment"}
leftSection={<IconCubeSend />}>
Отгрузка
</Tabs.Tab>
<Tabs.Tab
value={"employees"}
leftSection={<IconUsersGroup />}>
Исполнители
</Tabs.Tab>
</Tabs.List>
{getTabPanel("general", <DealEditDrawerGeneralTab />)}
{getTabPanel("client", <ClientTab />)}
{getTabPanel("history", <DealEditDrawerStatusChangeTable />)}
{getTabPanel("servicesAndProducts", <ProductAndServiceTab />)}
{getTabPanel("shipment", <ShippingTab />)}
{getTabPanel("employees", <EmployeesTab />)}
</Tabs>
</Drawer>
);
};
export default DealEditDrawer;

View File

@@ -1,98 +0,0 @@
import { Button, Fieldset, Flex, rem, Textarea, TextInput } from "@mantine/core";
import { useDealPageContext } from "../../../contexts/DealPageContext.tsx";
import { useForm } from "@mantine/form";
import { DealGeneralFormType } from "./DealEditDrawerGeneralTab.tsx";
import { ClientService, DealSchema, DealService } from "../../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
const ClientTab = () => {
const { selectedDeal: deal, setSelectedDeal } = useDealPageContext();
const initialValues: DealGeneralFormType = deal as DealSchema;
const queryClient = useQueryClient();
const form = useForm<DealGeneralFormType>(
{
initialValues: initialValues,
validate: {
name: (value: string) => value.length > 0 ? null : "Название сделки не может быть пустым",
},
},
);
const hasChanges = !isEqual(form.values, initialValues);
const updateClientInfo = async (values: DealGeneralFormType) => {
return ClientService.updateClient({
requestBody: {
data: values.client,
},
}).then(({ ok, message }) => notifications.guess(ok, { message }));
};
const update = async () => {
return DealService.getDealById({ dealId: form.values.id }).then(data => {
setSelectedDeal(data);
form.setInitialValues(data);
queryClient.invalidateQueries({
queryKey: ["getDealSummaries"],
});
});
};
const handleSave = () => {
updateClientInfo(form.values).then(async () => {
await update();
});
};
const handleCancel = () => {
form.setInitialValues(initialValues);
};
return (
<Flex direction={"column"} flex={1} gap={rem(10)}>
<Flex flex={1}>
<Fieldset legend={"Клиент"} flex={1}>
<TextInput
disabled
placeholder={"Название"}
label={"Название"}
value={deal?.client.name}
/>
<TextInput
placeholder={"Введите телефон"}
label={"Телефон клиента"}
{...form.getInputProps("client.details.phoneNumber")}
/>
<TextInput
placeholder={"Введите email"}
label={"Email"}
{...form.getInputProps("client.details.email")}
/>
<TextInput
placeholder={"Введите телеграм"}
label={"Телеграм"}
{...form.getInputProps("client.details.telegram")}
/>
<TextInput
placeholder={"Введите ИНН"}
label={"ИНН"}
{...form.getInputProps("client.details.inn")}
/>
<Textarea
placeholder={"Введите комментарий"}
label={"Комментарий"}
{...form.getInputProps("client.comment")}
/>
</Fieldset>
</Flex>
<Flex
gap={rem(10)}
justify={"flex-end"}
display={!hasChanges ? "none" : "flex"}
>
<Button onClick={handleCancel} variant={"default"}>Отмена</Button>
<Button onClick={handleSave} variant={"default"}>Сохранить</Button>
</Flex>
</Flex>
);
};
export default ClientTab;

View File

@@ -1,373 +0,0 @@
import { FC, useState } from "react";
import { useDealPageContext } from "../../../contexts/DealPageContext.tsx";
import {
ActionIcon,
Button,
Checkbox,
Divider,
Fieldset,
Flex,
Group,
rem,
Textarea,
TextInput,
Tooltip,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import {
ClientService,
DealSchema,
DealService,
ProjectSchema,
ShippingWarehouseSchema,
StatusSchema,
} from "../../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
import ShippingWarehouseAutocomplete
from "../../../../../components/Selects/ShippingWarehouseAutocomplete/ShippingWarehouseAutocomplete.tsx";
import { ButtonCopyControlled } from "../../../../../components/ButtonCopyControlled/ButtonCopyControlled.tsx";
import { useClipboard } from "@mantine/hooks";
import ButtonCopy from "../../../../../components/ButtonCopy/ButtonCopy.tsx";
import FileSaver from "file-saver";
import { dateWithoutTimezone, getCurrentDateTimeForFilename } from "../../../../../shared/lib/date.ts";
import { IconBarcode, IconPrinter } from "@tabler/icons-react";
import styles from "../../../ui/DealsPage.module.css";
import { base64ToBlob } from "../../../../../shared/lib/utils.ts";
import { DatePickerInput } from "@mantine/dates";
import ManagerSelect from "../../../../../components/ManagerSelect/ManagerSelect.tsx";
import ProjectSelect from "../../../../../components/ProjectSelect/ProjectSelect.tsx";
import BoardSelect from "../../../../../components/BoardSelect/BoardSelect.tsx";
import DealStatusSelect from "../../../../../components/DealStatusSelect/DealStatusSelect.tsx";
type Props = {
deal: DealSchema;
};
export type DealGeneralFormType = Omit<DealSchema, "statusHistory" | "services" | "products">;
const Content: FC<Props> = ({ deal }) => {
const { setSelectedDeal } = useDealPageContext();
const clipboard = useClipboard();
const queryClient = useQueryClient();
const [project, setProject] = useState<ProjectSchema | null>(deal.board.project);
// ignore typescript
const initialValues: DealGeneralFormType = {
...deal,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
deliveryDate: deal.deliveryDate ? new Date(deal.deliveryDate) : null,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
receivingSlotDate: deal.receivingSlotDate ? new Date(deal.receivingSlotDate) : null,
};
const form = useForm<DealGeneralFormType>({
initialValues: initialValues,
validate: {
name: (value: string) =>
value.length > 0
? null
: "Название сделки не может быть пустым",
status: (value: StatusSchema) =>
!value && "Статус для сделки не выбран",
},
});
const updateDealInfo = async (values: DealGeneralFormType) => {
return DealService.updateDealGeneralInfo({
requestBody: {
dealId: deal.id,
data: {
...values,
shippingWarehouse: values.shippingWarehouse?.toString(),
},
},
}).then(({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
DealService.getDealById({ dealId: deal.id }).then(data => {
setSelectedDeal(data);
form.setInitialValues(data);
queryClient.invalidateQueries({
queryKey: ["getDealSummaries"],
});
});
});
};
const updateClientInfo = async (values: DealGeneralFormType) => {
return ClientService.updateClient({
requestBody: {
data: values.client,
},
}).then(({ ok, message }) => notifications.guess(ok, { message }));
};
const handleSubmit = async (values: DealGeneralFormType) => {
// Updating client info if there changes
if (!isEqual(values.client, deal.client)) {
await updateClientInfo(values);
}
// updating deal info
// get shimpent warehouse name from object if its object, otherwise just pass it
const shippingWarehouse = isShippingWarehouse(values.shippingWarehouse) ? values.shippingWarehouse.name : values.shippingWarehouse;
await updateDealInfo({
...values,
deliveryDate: values.deliveryDate ? dateWithoutTimezone(new Date(values.deliveryDate)) : null,
receivingSlotDate: values.receivingSlotDate ? dateWithoutTimezone(new Date(values.receivingSlotDate)) : null,
shippingWarehouse: shippingWarehouse,
});
};
const isShippingWarehouse = (
value: ShippingWarehouseSchema | string | null | undefined,
): value is ShippingWarehouseSchema => {
return !["string", "null", "undefined"].includes(typeof value);
};
const onCopyGuestUrlClick = () => {
DealService.createDealGuestUrl({
requestBody: {
dealId: deal.id,
},
}).then(({ ok, message, url }) => {
if (!ok) notifications.guess(ok, { message });
clipboard.copy(`${window.location.origin}/${url}`);
});
};
const billRequestPdfUrl = deal?.billRequest?.pdfUrl || deal?.group?.billRequest?.pdfUrl;
return (
<form onSubmit={form.onSubmit(values => handleSubmit(values))}>
<Flex
direction={"column"}
justify={"space-between"}
h={"100%"}>
<Fieldset legend={`Общие параметры [ID: ${deal.id}]`}>
<Flex
direction={"column"}
gap={rem(10)}>
<TextInput
placeholder={"Название сделки"}
label={"Название сделки"}
{...form.getInputProps("name")}
/>
<TextInput
disabled
placeholder={"Дата создания"}
label={"Дата создания"}
value={new Date(deal.createdAt).toLocaleString(
"ru-RU",
)}
/>
<ProjectSelect
value={project}
onChange={setProject}
label={"Проект"}
/>
<BoardSelect
project={project}
{...form.getInputProps("board")}
label={"Доска"}
/>
<DealStatusSelect
board={form.values.board}
{...form.getInputProps("status")}
label={"Статус"}
/>
<Textarea
h={rem(150)}
styles={{
wrapper: {height:'90%'},
input: {height:'90%'}
}}
label={"Коментарий к сделке"}
placeholder={"Введите коментарий к сделке"}
{...form.getInputProps("comment")}
/>
<ShippingWarehouseAutocomplete
placeholder={"Введите склад отгрузки"}
label={"Склад отгрузки"}
value={
isShippingWarehouse(
form.values.shippingWarehouse,
)
? form.values.shippingWarehouse
: undefined
}
onChange={event => {
if (isShippingWarehouse(event)) {
form.getInputProps(
"shippingWarehouse",
).onChange(event.name);
return;
}
form.getInputProps(
"shippingWarehouse",
).onChange(event);
}}
/>
<DatePickerInput
minDate={new Date()}
placeholder={"Укажите дату передачи в доставку"}
label={"Дата передачи в доставку"}
{...form.getInputProps("deliveryDate")}
/>
<DatePickerInput
minDate={new Date()}
placeholder={"Укажите слот приемки"}
label={"Слот приемки"}
{...form.getInputProps("receivingSlotDate")}
/>
<ManagerSelect
placeholder={"Укажите менеджера"}
label={"Менеджер"}
{...form.getInputProps("manager")}
/>
</Flex>
</Fieldset>
<Flex
mt={"md"}
gap={rem(10)}
align={"center"}
justify={"flex-end"}>
<Flex
align={"center"}
gap={rem(10)}
justify={"center"}>
<Flex
gap={rem(10)}
align={"center"}
justify={"space-between"}>
<Tooltip
className={styles["print-deals-button"]}
label={"Распечатать штрихкоды сделки"}
>
<ActionIcon
onClick={async () => {
const response =
await DealService.getDealProductsBarcodesPdf({
requestBody: {
dealId: deal.id,
},
});
const pdfBlob = base64ToBlob(
response.base64String,
response.mimeType,
);
const pdfUrl = URL.createObjectURL(pdfBlob);
const pdfWindow = window.open(pdfUrl);
if (!pdfWindow) {
notifications.error({ message: "Ошибка" });
return;
}
pdfWindow.onload = () => {
pdfWindow.print();
};
}}
variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip label={"Распечатать сделку"}>
<ActionIcon
onClick={() => {
const pdfWindow = window.open(
`${import.meta.env.VITE_API_URL}/deal/tech-spec/${deal.id}`,
);
if (!pdfWindow) return;
pdfWindow.print();
}}
variant={"default"}>
<IconPrinter />
</ActionIcon>
</Tooltip>
<Flex gap={rem(10)}>
{billRequestPdfUrl ? (
<ButtonCopy
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
value={billRequestPdfUrl}>
Ссылка на оплату
</ButtonCopy>
) : (
<ButtonCopyControlled
onCopyClick={() => {
const date =
getCurrentDateTimeForFilename();
FileSaver.saveAs(
`${import.meta.env.VITE_API_URL}/deal/billing-document/${deal.id}`,
`bill_${deal.id}_${date}.pdf`,
);
}}
copied={false}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}>
Ссылка на оплату (PDF)
</ButtonCopyControlled>
)}
<ButtonCopyControlled
onCopyClick={onCopyGuestUrlClick}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
copied={clipboard.copied}>
Ссылка на редактирование
</ButtonCopyControlled>
</Flex>
</Flex>
<Flex gap={rem(10)}>
<Checkbox
label={"Оплачен"}
checked={deal.billRequest?.paid || deal.group?.billRequest?.paid || false}
disabled
/>
<Checkbox
label={"Учет выручки"}
{...form.getInputProps("isAccounted", { type: "checkbox" })}
/>
<Checkbox
label={"Завершена"}
{...form.getInputProps("isCompleted", { type: "checkbox" })}
/>
<Checkbox
label={"Удалена"}
{...form.getInputProps("isDeleted", { type: "checkbox" })}
/>
</Flex>
</Flex>
<Divider orientation={"vertical"} />
<Group
align={"center"}
justify={"center"}>
<Button
color={"red"}
type={"reset"}
disabled={isEqual(initialValues, form.values)}
onClick={() => form.reset()}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqual(initialValues, form.values)}>
Сохранить изменения
</Button>
</Group>
</Flex>
</Flex>
</form>
);
};
const DealEditDrawerGeneralTab: FC = () => {
const { selectedDeal } = useDealPageContext();
if (!selectedDeal) return <>No deal selected</>;
return <Content deal={selectedDeal} />;
};
export default DealEditDrawerGeneralTab;

View File

@@ -1,63 +0,0 @@
import { BoardSchema, DealSummary } from "../../../client";
import { DragStart, DropResult } from "@hello-pangea/dnd";
import { useState } from "react";
import DragState from "../enums/DragState.ts";
import useDealsDnd from "../../../components/Dnd/Deals/DealsDndColumn/hooks/useDealsDnd.tsx";
import useStatusesDnd from "../../../components/Dnd/Statuses/Statuses/hooks/useStatusesDnd.tsx";
type Props = {
selectedBoard: BoardSchema | null;
summariesRaw: DealSummary[];
refetchSummaries: () => void;
refetchBoards: () => void;
}
const useDnd = ({
selectedBoard,
summariesRaw,
refetchSummaries,
refetchBoards,
}: Props) => {
const [dragState, setDragState] = useState<DragState>(DragState.DRAG_ENDED);
const {
summaries,
onDealDragEnd,
} = useDealsDnd({
summariesRaw,
refetchSummaries,
})
const {
onStatusDragEnd,
} = useStatusesDnd({
board: selectedBoard,
refetch: refetchBoards,
});
const onDragEnd = async (result: DropResult) => {
setDragState(DragState.DRAG_ENDED);
if (result.draggableId.includes("status")) {
return onStatusDragEnd(result);
}
return onDealDragEnd(result);
}
const onDragStart = (start: DragStart) => {
if (start.source.droppableId.includes("status")) {
setDragState(DragState.DRAG_STATUS);
} else {
setDragState(DragState.DRAG_DEAL);
}
}
return {
summaries,
dragState,
onDragStart,
onDragEnd,
};
};
export default useDnd;

View File

@@ -1,26 +0,0 @@
import { useEffect, useState } from "react";
import { type ProjectSchemaWithCount, ProjectService } from "../../../client";
const useProjects = () => {
const [projects, setProjects] = useState<ProjectSchemaWithCount[]>([]);
const refetchProjects = () => {
ProjectService.getProjects()
.then(data => {
setProjects(data.projects);
})
.catch(e => console.log(e));
};
useEffect(() => {
refetchProjects();
}, []);
return {
projects,
refetchProjects,
};
};
export default useProjects;

View File

@@ -1 +0,0 @@
export { DealsPage } from "./ui/DealsPage.tsx";

View File

@@ -1,90 +0,0 @@
import { ContextModalProps } from "@mantine/modals";
import BaseFormModal, {
CreateEditFormProps,
} from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import { DealProductSchema, DealProductServiceSchema } from "../../../client";
import { useForm } from "@mantine/form";
import { NumberInput } from "@mantine/core";
import ProductSelect from "../../../components/ProductSelect/ProductSelect.tsx";
import { omit } from "lodash";
type RestProps = {
clientId: number;
productIds?: number[];
};
type Props = CreateEditFormProps<DealProductSchema> & RestProps;
const AddDealProductModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "element" in innerProps;
const restProps = omit(innerProps, ["clientId"]);
const validateServices = (services?: DealProductServiceSchema[]) => {
if (!services || services.length == 0) return null;
return services.find(service => service.service === undefined)
? "Удалите пустые услуги"
: null;
};
const form = useForm<Partial<DealProductSchema>>({
initialValues: isEditing
? innerProps.element
: {
product: undefined,
services: [],
quantity: 1,
},
validate: {
product: (product?: DealProductSchema["product"]) =>
product !== undefined ? null : "Необходимо выбрать товар",
quantity: (quantity?: number) =>
quantity && quantity > 0
? null
: "Количество должно быть больше 0",
services: validateServices,
},
});
const onClose = () => {
context.closeContextModal(id);
};
return (
<BaseFormModal
{...(restProps as CreateEditFormProps<DealProductSchema>)}
form={form}
closeOnSubmit
onClose={onClose}>
<BaseFormModal.Body>
<>
<ProductSelect
placeholder={"Выберите товар"}
label={"Товар"}
clientId={innerProps.clientId}
disabled={isEditing}
filterBy={item =>
!(innerProps.productIds || []).includes(item.id)
}
{...form.getInputProps("product")}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
{/*<Fieldset legend={'Услуги'}>*/}
{/* <DealProductServiceTable*/}
{/* quantity={form.values.quantity || 1}*/}
{/* {...form.getInputProps('services') as*/}
{/* BaseFormInputProps<DealProductServiceSchema[]>}*/}
{/* />*/}
{/*</Fieldset>*/}
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default AddDealProductModal;

View File

@@ -1,106 +0,0 @@
import { ContextModalProps } from "@mantine/modals";
import BaseFormModal, { CreateEditFormProps } from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import { DealServiceSchema } from "../../../client";
import { useForm } from "@mantine/form";
import { ComboboxItem, ComboboxItemGroup, NumberInput, OptionsFilter } from "@mantine/core";
import ServiceWithPriceInput from "../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { ServiceType } from "../../../shared/enums/ServiceType.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
type RestProps = {
serviceIds?: number[];
};
type Props = CreateEditFormProps<Partial<DealServiceSchema>> & RestProps;
const AddDealServiceModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const authState = useSelector((state: RootState) => state.auth);
const isEditing = "element" in innerProps;
const form = useForm<Partial<DealServiceSchema>>({
initialValues: isEditing
? innerProps.element
: {
service: undefined,
quantity: 1,
employees: [],
},
validate: {
service: (service?: DealServiceSchema["service"]) =>
service !== undefined ? null : "Необходимо выбрать услугу",
quantity: (quantity?: number) =>
quantity && quantity > 0
? null
: "Количество должно быть больше 0",
},
});
const onClose = () => {
context.closeContextModal(id);
};
const serviceOptionsFilter = ({
options,
}: {
options: ComboboxItemGroup[];
}) => {
if (!innerProps.serviceIds) return options;
const productServiceIds = innerProps.serviceIds;
return (options as ComboboxItemGroup[]).map(({ items, group }) => {
return {
group,
items: items.filter(
item =>
!productServiceIds.includes(
parseInt((item as ComboboxItem).value)
)
),
};
});
};
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={onClose}>
<BaseFormModal.Body>
<>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
label: "Услуга",
placeholder: "Выберите услугу",
style: { width: "100%" },
disabled: isEditing,
filter: serviceOptionsFilter as OptionsFilter,
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
style: { width: "100%" },
disabled: authState.isGuest,
}}
quantity={form.values.quantity || 1}
containerProps={{
direction: "column",
style: { width: "100%" },
}}
filterType={ServiceType.DEAL_SERVICE}
lockOnEdit={isEditing}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default AddDealServiceModal;

View File

@@ -1,97 +0,0 @@
import BaseFormModal, {
CreateEditFormProps,
} from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import {
DealProductServiceSchema,
ServiceSchema,
} from "../../../client";
import { ContextModalProps } from "@mantine/modals";
import { useForm, UseFormReturnType } from "@mantine/form";
import { isNil, isNumber } from "lodash";
import ServiceWithPriceInput from "../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { Checkbox, Flex, rem } from "@mantine/core";
import { ServiceType } from "../../../shared/enums/ServiceType.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
type RestProps = {
quantity: number;
serviceIds: number[];
};
type Props = CreateEditFormProps<DealProductServiceSchema> & RestProps;
const ProductServiceFormModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const authState = useSelector((state: RootState) => state.auth);
const isEditing = "onChange" in innerProps;
const initialValues: Partial<DealProductServiceSchema> = isEditing
? innerProps.element
: {
service: undefined,
price: undefined,
employees: [],
isFixedPrice: false,
};
const form = useForm<Partial<DealProductServiceSchema>>({
initialValues,
validate: {
service: (service?: ServiceSchema) =>
isNil(service) || service.id < 0 ? "Укажите услугу" : null,
price: (price?: number) =>
!isNumber(price) || price < 0 ? "Укажите цену" : null,
},
});
const onClose = () => {
context.closeContextModal(id);
};
return (
<BaseFormModal
{...innerProps}
form={form as UseFormReturnType<DealProductServiceSchema>}
onClose={onClose}
closeOnSubmit>
<BaseFormModal.Body>
<>
<Flex w={"100%"} direction={"column"} gap={rem(10)}>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
label: "Услуга",
placeholder: "Выберите услугу",
disabled: isEditing,
filterBy: item =>
!innerProps.serviceIds.includes(item.id) ||
isEditing,
style: { width: "100%" },
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
style: { width: "100%" },
disabled: authState.isGuest,
}}
filterType={ServiceType.PRODUCT_SERVICE}
containerProps={{
direction: "column",
style: { width: "100%" },
}}
lockOnEdit={isEditing}
quantity={innerProps.quantity}
/>
<Checkbox
{...form.getInputProps("isFixedPrice", { type: "checkbox" })}
label={"Зафиксировать цену"}
placeholder={"Зафиксировать цену"}
/>
</Flex>
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default ProductServiceFormModal;

View File

@@ -1,85 +0,0 @@
import { type ProjectSchemaWithCount } from "../../../../client";
import { ContextModalProps } from "@mantine/modals";
import { ActionIcon, Flex, rem, Stack, TextInput, Tooltip } from "@mantine/core";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import useProjectsTableColumns from "./hooks/projectsTableColumns.tsx";
import { IconCheck, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
import useProjectModal from "./hooks/useProjectModal.tsx";
type Props = {
onUpdate: () => void;
};
const ProjectsModal = ({ innerProps }: ContextModalProps<Props>) => {
const {
projects,
name,
setName,
editingProjects,
handleEditClick,
handleDeleteClick,
handleCreateClick,
} = useProjectModal(innerProps);
const columns = useProjectsTableColumns({ editingProjects });
return (
<Stack gap={rem(10)}>
<TextInput
label={"Добавить проект"}
variant={"default"}
value={name}
onChange={e => setName(e.target.value)}
/>
<InlineButton
variant={"default"}
onClick={handleCreateClick}>
<IconPlus />
Добавить
</InlineButton>
<BaseTable
data={projects}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => handleEditClick(row.original)}
variant={"default"}>
{
editingProjects.has(row.original.id) ? (
<IconCheck />
) : (
<IconEdit />
)
}
</ActionIcon>
</Tooltip>
<Tooltip label={"Удалить"}>
<ActionIcon
onClick={() => handleDeleteClick(row.original)}
disabled={row.original.boardsCount > 0}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<ProjectSchemaWithCount>
}
/>
</Stack>
);
};
export default ProjectsModal;

View File

@@ -1,100 +0,0 @@
import { BaseProjectSchema, ProjectSchema, ProjectService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useMap } from "@mantine/hooks";
import { useState } from "react";
import useProjects from "../../../hooks/useProjects.tsx";
type Props = {
onUpdate: () => void;
}
const useProjectModal = ({ onUpdate }: Props) => {
const editingProjects = useMap<number, ProjectSchema>();
const { projects, refetchProjects } = useProjects();
const [name, setName] = useState("");
const updateProject = (project: ProjectSchema) => {
ProjectService.updateProject({
requestBody: {
project,
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
editingProjects.delete(project.id);
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const handleEditClick = (project: ProjectSchema) => {
const editedProject = editingProjects.get(project.id);
if (!editedProject) {
editingProjects.set(project.id, project);
return;
}
if (editedProject.name.length === 0) {
notifications.error({ message: "Имя проекта не может быть пустым" });
return;
}
updateProject(project);
};
const handleDeleteClick = (project: ProjectSchema) => {
ProjectService.deleteProject({
projectId: project.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const createProject = (project: BaseProjectSchema) => {
ProjectService.createProject({
requestBody: { project },
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
setName("");
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const handleCreateClick = () => {
if (name.length === 0) {
notifications.error({ message: "Имя проекта не может быть пустым" });
return;
}
createProject({ name });
};
return {
projects,
name,
setName,
editingProjects,
handleEditClick,
handleDeleteClick,
handleCreateClick,
};
};
export default useProjectModal;

Some files were not shown because too many files have changed in this diff Show More