feat: client tab in deal editor

This commit is contained in:
2025-10-05 12:05:23 +04:00
parent d14920df7d
commit 0fcf086861
21 changed files with 511 additions and 35 deletions

View File

@ -40,6 +40,7 @@
"libphonenumber-js": "^1.12.10",
"mantine-datatable": "^8.2.0",
"next": "15.4.7",
"phone": "^3.1.67",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-imask": "^7.6.1",

View File

@ -16,7 +16,11 @@ type UseClientsProps = {
export type ClientsCrud = {
onCreate: (client: CreateClientSchema) => void;
onUpdate: (clientId: number, client: UpdateClientSchema) => void;
onUpdate: (
clientId: number,
client: UpdateClientSchema,
onSuccess?: () => void
) => void;
onDelete: (client: ClientSchema) => void;
};

View File

@ -5,8 +5,16 @@ import {
getClientsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
const useClientsList = () => {
const { data, refetch } = useQuery(getClientsOptions());
type Props = {
includeDeleted?: boolean;
};
const useClientsList = (
{ includeDeleted = false }: Props = { includeDeleted: false }
) => {
const { data, refetch } = useQuery(
getClientsOptions({ query: { includeDeleted } })
);
const clients = useMemo(() => data?.items ?? [], [data]);
const queryKey = getClientsQueryKey();

View File

@ -2,9 +2,15 @@ import { FC } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { ClientSchema } from "@/lib/client";
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/shared/components/ClientSelect";
import { ModuleNames } from "@/modules/modules";
export type CreateDealForm = {
name: string;
client?: ClientSchema;
clientId?: number;
};
type Props = {
@ -13,17 +19,24 @@ type Props = {
};
const CreateCardForm: FC<Props> = ({ onSubmit, onCancel }) => {
const { modulesSet } = useProjectsContext();
const form = useForm<CreateDealForm>({
initialValues: {
name: "",
},
validate: {
name: value => !value && "Введите название",
client: client =>
modulesSet.has(ModuleNames.CLIENTS) &&
!client &&
"Выберите клиента",
},
});
return (
<form onSubmit={form.onSubmit(values => {
<form
onSubmit={form.onSubmit(values => {
onSubmit(values);
form.reset();
})}>
@ -32,6 +45,16 @@ const CreateCardForm: FC<Props> = ({ onSubmit, onCancel }) => {
placeholder={"Название"}
{...form.getInputProps("name")}
/>
{modulesSet.has(ModuleNames.CLIENTS) && (
<ClientSelect
placeholder={"Клиент"}
{...form.getInputProps("client")}
onChange={client => {
form.setFieldValue("client", client);
form.setFieldValue("clientId", client?.id);
}}
/>
)}
<Group wrap={"nowrap"}>
<Button
variant={"default"}

View File

@ -3,6 +3,7 @@ import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext";
import { DealSchema } from "@/lib/client";
import { ModuleNames } from "@/modules/modules";
import styles from "./DealCard.module.css";
type Props = {
@ -10,7 +11,7 @@ type Props = {
};
const DealCard = ({ deal }: Props) => {
const { selectedProject } = useProjectsContext();
const { selectedProject, modulesSet } = useProjectsContext();
const { dealsCrud } = useDealsContext();
const { openDrawer } = useDrawersContext();
@ -47,6 +48,9 @@ const DealCard = ({ deal }: Props) => {
</Group>
<Stack className={styles["deal-data"]}>
<Stack gap={0}>
{modulesSet.has(ModuleNames.CLIENTS) && (
<Text>{deal.client?.name}</Text>
)}
<Text>Wb электросталь</Text>
<Text>19 000 руб.</Text>
<Text>130 тов.</Text>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { ProjectsCrud, useProjectsCrud } from "@/hooks/cruds/useProjectsCrud";
import useProjectsList from "@/hooks/lists/useProjectsList";
import { ProjectSchema } from "@/lib/client";
@ -12,6 +12,7 @@ type ProjectsContextState = {
refetchProjects: () => void;
projects: ProjectSchema[];
projectsCrud: ProjectsCrud;
modulesSet: Set<string>;
};
const useProjectsContextState = (): ProjectsContextState => {
@ -20,8 +21,16 @@ const useProjectsContextState = (): ProjectsContextState => {
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(
null
);
const selectedProject =
projects.find(project => project.id === selectedProjectId) ?? null;
const selectedProject = useMemo(
() =>
projects.find(project => project.id === selectedProjectId) ?? null,
[projects, selectedProjectId]
);
const modulesSet = useMemo(
() => new Set(selectedProject?.builtInModules.map(m => m.key)),
[selectedProject]
);
if (selectedProject === null && projects.length > 0) {
setSelectedProjectId(projects[0].id);
@ -35,6 +44,7 @@ const useProjectsContextState = (): ProjectsContextState => {
refetchProjects,
setSelectedProjectId,
projectsCrud,
modulesSet,
};
};

View File

@ -1,9 +1,10 @@
import React, { FC } from "react";
import { Flex, Stack, TextInput } from "@mantine/core";
import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import Footer from "@/app/deals/drawers/DealEditorDrawer/tabs/GeneralTab/components/Footer";
import BoardSelect from "@/components/selects/BoardSelect/BoardSelect";
import StatusSelect from "@/components/selects/StatusSelect/StatusSelect";
import FormFlexRow from "@/components/ui/FormFlexRow/FormFlexRow";
import { BoardSchema, DealSchema, StatusSchema } from "@/lib/client";
import { utcDateTimeToLocalString } from "@/utils/datetime";
@ -40,10 +41,7 @@ const GeneralTab: FC<Props> = ({ value, onDelete, onChange }) => {
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack p={"md"}>
<Flex
gap={"md"}
w={"100%"}
direction={{ base: "column", sm: "row" }}>
<FormFlexRow>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
@ -55,11 +53,8 @@ const GeneralTab: FC<Props> = ({ value, onDelete, onChange }) => {
readOnly
flex={1}
/>
</Flex>
<Flex
gap={"md"}
w={"100%"}
direction={{ base: "column", sm: "row" }}>
</FormFlexRow>
<FormFlexRow>
<BoardSelect
label={"Доска"}
{...form.getInputProps("board")}
@ -76,7 +71,7 @@ const GeneralTab: FC<Props> = ({ value, onDelete, onChange }) => {
boardId={form.values.board?.id}
flex={1}
/>
</Flex>
</FormFlexRow>
<Footer
form={form}
onDelete={() => onDelete(value)}

View File

@ -0,0 +1,13 @@
import { FC, PropsWithChildren } from "react";
import { Flex } from "@mantine/core";
const FormFlexRow: FC<PropsWithChildren> = ({ children }) => (
<Flex
gap={"md"}
w={"100%"}
direction={{ base: "column", sm: "row" }}>
{children}
</Flex>
);
export default FormFlexRow;

View File

@ -11,7 +11,6 @@ import {
deleteDealMutation,
updateDealMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { getNewLexorank } from "@/utils/lexorank/generation";
type UseDealsOperationsProps = {
@ -56,6 +55,7 @@ export const useDealsCrud = ({
firstDeal ? LexoRank.parse(firstDeal.lexorank) : null
);
return {
...data,
name: data.name!,
boardId,
statusId: firstStatus.id,
@ -72,6 +72,7 @@ export const useDealsCrud = ({
board: update.boardId
? { ...old.board, id: update.boardId }
: old.board,
client: update.client ?? old.client,
}),
getDeleteConfirmTitle: () => "Удаление сделки",
});

View File

@ -102,6 +102,7 @@ import type {
GetBuiltInModulesData,
GetBuiltInModulesResponses,
GetClientsData,
GetClientsErrors,
GetClientsResponses,
GetDealProductsData,
GetDealProductsErrors,
@ -759,7 +760,7 @@ export const getClients = <ThrowOnError extends boolean = false>(
) => {
return (options?.client ?? _heyApiClient).get<
GetClientsResponses,
unknown,
GetClientsErrors,
ThrowOnError
>({
requestValidator: async data => {

View File

@ -214,6 +214,10 @@ export type ClientSchema = {
* Id
*/
id: number;
/**
* Isdeleted
*/
isDeleted?: boolean;
};
/**
@ -403,6 +407,10 @@ export type CreateDealSchema = {
* Statusid
*/
statusId: number;
/**
* Clientid
*/
clientId?: number | null;
};
/**
@ -831,6 +839,7 @@ export type DealSchema = {
* Createdat
*/
createdAt: string;
client?: ClientSchema | null;
};
/**
@ -1624,6 +1633,7 @@ export type UpdateDealSchema = {
* Statusid
*/
statusId?: number | null;
client?: ClientSchema | null;
};
/**
@ -2497,10 +2507,24 @@ export type GetStatusHistoryResponse2 =
export type GetClientsData = {
body?: never;
path?: never;
query?: never;
query?: {
/**
* Includedeleted
*/
includeDeleted?: boolean;
};
url: "/modules/clients/client/";
};
export type GetClientsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetClientsError = GetClientsErrors[keyof GetClientsErrors];
export type GetClientsResponses = {
/**
* Successful Response

View File

@ -108,6 +108,7 @@ export const zClientSchema = z.object({
comment: z.optional(z.union([z.string(), z.null()])),
details: zClientDetailsSchema,
id: z.int(),
isDeleted: z.optional(z.boolean()).default(false),
});
/**
@ -290,6 +291,7 @@ export const zCreateDealSchema = z.object({
boardId: z.int(),
lexorank: z.string(),
statusId: z.int(),
clientId: z.optional(z.union([z.int(), z.null()])),
});
/**
@ -320,6 +322,7 @@ export const zDealSchema = z.object({
createdAt: z.iso.datetime({
offset: true,
}),
client: z.optional(z.union([zClientSchema, z.null()])),
});
/**
@ -964,6 +967,7 @@ export const zUpdateDealSchema = z.object({
lexorank: z.optional(z.union([z.string(), z.null()])),
boardId: z.optional(z.union([z.int(), z.null()])),
statusId: z.optional(z.union([z.int(), z.null()])),
client: z.optional(z.union([zClientSchema, z.null()])),
});
/**
@ -1409,7 +1413,11 @@ export const zGetStatusHistoryResponse2 = zGetStatusHistoryResponse;
export const zGetClientsData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),
query: z.optional(z.never()),
query: z.optional(
z.object({
includeDeleted: z.optional(z.boolean()).default(false),
})
),
});
/**

View File

@ -0,0 +1,29 @@
import { FC } from "react";
import { Stack } from "@mantine/core";
import { DealSchema } from "@/lib/client";
import ClientDataForm from "@/modules/dealModularEditorTabs/Clients/shared/components/ClientDataForm";
import ClientSelectForm from "@/modules/dealModularEditorTabs/Clients/shared/components/ClientSelectForm";
import { ClientTabContextProvider } from "@/modules/dealModularEditorTabs/Clients/shared/contexts/ClientTabContext";
type Props = {
value: DealSchema;
onChange: (value: DealSchema) => void;
};
const ClientTab: FC<Props> = ({ value, onChange }) => {
return (
<Stack
flex={1}
mt={"xs"}
mx={"xs"}>
<ClientTabContextProvider
value={value}
onChange={onChange}>
<ClientSelectForm />
<ClientDataForm />
</ClientTabContextProvider>
</Stack>
);
};
export default ClientTab;

View File

@ -0,0 +1,119 @@
import { FC } from "react";
import { IMaskInput } from "react-imask";
import {
Button,
Fieldset,
Group,
Input,
Stack,
Textarea,
TextInput,
} from "@mantine/core";
import { useClientsCrud } from "@/app/clients/hooks/useClientsCrud";
import FormFlexRow from "@/components/ui/FormFlexRow/FormFlexRow";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { ClientSchema } from "@/lib/client";
import { getClientsQueryKey } from "@/lib/client/@tanstack/react-query.gen";
import { useClientTabContext } from "../contexts/ClientTabContext";
const ClientDataForm: FC = () => {
const { client, form, isEditorDisabled } = useClientTabContext();
const isMobile = useIsMobile();
const clientsQueryKey = getClientsQueryKey();
const clientsCrud = useClientsCrud({ queryKey: clientsQueryKey });
const handleSubmitClientInfo = (values: ClientSchema) => {
if (!client) return;
if (values.details?.phoneNumber === "+7 ")
values.details!.phoneNumber = "";
clientsCrud.onUpdate(client.id, values, () => form.resetDirty());
};
return (
<Fieldset
radius={"md"}
legend={"Данные клиента"}
flex={1}>
<form
onSubmit={form.onSubmit(values =>
handleSubmitClientInfo(values as ClientSchema)
)}>
<Stack gap={"xs"}>
<FormFlexRow>
<TextInput
disabled
placeholder={"Название"}
label={"Название"}
{...form.getInputProps("name")}
flex={1}
/>
<TextInput
disabled
placeholder={"Наименование компании"}
label={"Наименование компании"}
{...form.getInputProps("companyName")}
flex={1}
/>
</FormFlexRow>
<FormFlexRow>
<Input.Wrapper
label={"Номер телефона"}
flex={1}
error={
form.getInputProps("details.phoneNumber").error
}>
<Input
component={IMaskInput}
mask="+7 000 000-00-00"
placeholder={"Введите номер телефона"}
{...form.getInputProps("details.phoneNumber")}
disabled={isEditorDisabled}
/>
</Input.Wrapper>
<TextInput
disabled={isEditorDisabled}
placeholder={"Введите email"}
label={"Email"}
{...form.getInputProps("details.email")}
flex={1}
/>
</FormFlexRow>
<FormFlexRow>
<TextInput
disabled={isEditorDisabled}
placeholder={"Введите телеграм"}
label={"Телеграм"}
{...form.getInputProps("details.telegram")}
flex={1}
/>
<TextInput
disabled={isEditorDisabled}
placeholder={"Введите ИНН"}
label={"ИНН"}
{...form.getInputProps("details.inn")}
flex={1}
/>
</FormFlexRow>
<Textarea
disabled={isEditorDisabled}
placeholder={"Введите комментарий"}
label={"Комментарий"}
{...form.getInputProps("comment")}
/>
<Group>
<Button
variant={"default"}
disabled={isEditorDisabled || !form.isDirty()}
w={isMobile ? "100%" : "auto"}
type={"submit"}>
Сохранить
</Button>
</Group>
</Stack>
</form>
</Fieldset>
);
};
export default ClientDataForm;

View File

@ -0,0 +1,68 @@
import { FC, useEffect, useMemo, useState } from "react";
import { Text } from "@mantine/core";
import useClientsList from "@/app/clients/hooks/useClientsList";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { ClientSchema, DealSchema } from "@/lib/client";
type RestProps = {
includeDeleted?: boolean;
deal?: DealSchema;
};
type Props = Omit<ObjectSelectProps<ClientSchema>, "data"> & RestProps;
const ClientSelect: FC<Props> = ({ includeDeleted = false, ...props }) => {
const { clients } = useClientsList({ includeDeleted });
const filteredClients = useMemo(
() =>
clients.filter(
client =>
!client.isDeleted ||
(includeDeleted && client.id === props.deal?.client?.id)
),
[clients]
);
const [deletedClientIds, setDeletedClientIds] = useState<Set<number>>(
new Set()
);
const [warning, setWarning] = useState<string>("");
useEffect(() => {
const deletedClientIds = new Set(
clients.filter(client => client.isDeleted).map(client => client.id)
);
setDeletedClientIds(deletedClientIds);
}, [clients]);
useEffect(() => {
if (props.value && deletedClientIds.has(props.value.id)) {
setWarning("Выбран удаленный клиент");
} else {
setWarning("");
}
}, [props.value, deletedClientIds]);
return (
<>
<ObjectSelect
searchable
data={filteredClients}
placeholder={"Выберите клиента"}
{...props}
/>
{warning && (
<Text
size={"sm"}
c={"yellow"}>
{warning}
</Text>
)}
</>
);
};
export default ClientSelect;

View File

@ -0,0 +1,44 @@
import { Fieldset, Group, Stack } from "@mantine/core";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useIsMobile from "@/hooks/utils/useIsMobile";
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/shared/components/ClientSelect";
import { useClientTabContext } from "../contexts/ClientTabContext";
const ClientSelectForm = () => {
const {
client,
setClient,
submitClientSelection,
form,
isEditorDisabled,
deal,
} = useClientTabContext();
const isMobile = useIsMobile();
return (
<Fieldset
legend={"Выбор клиента"}
radius={"md"}
flex={1}>
<Stack gap={"xs"}>
<ClientSelect
value={client}
onChange={setClient}
disabled={form.isDirty()}
deal={deal}
includeDeleted
/>
<Group>
<InlineButton
onClick={submitClientSelection}
disabled={!isEditorDisabled}
w={isMobile ? "100%" : "auto"}>
Сохранить
</InlineButton>
</Group>
</Stack>
</Fieldset>
);
};
export default ClientSelectForm;

View File

@ -0,0 +1,92 @@
"use client";
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import phone from "phone";
import { useForm, UseFormReturnType } from "@mantine/form";
import isValidInn from "@/app/clients/utils/isValidInn";
import { ClientSchema, DealSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
type ClientTabContextState = {
form: UseFormReturnType<Partial<ClientSchema>>;
client: ClientSchema | undefined;
setClient: Dispatch<SetStateAction<ClientSchema | undefined>>;
isEditorDisabled: boolean;
submitClientSelection: () => void;
deal: DealSchema;
};
type Props = {
value: DealSchema;
onChange: (value: DealSchema) => void;
};
const useClientTabContextState = ({
value,
onChange,
}: Props): ClientTabContextState => {
const initialValues = value?.client ?? {
name: "",
companyName: "",
comment: "",
isDeleted: false,
details: { email: "", inn: "", phoneNumber: "", telegram: "" },
};
const [client, setClient] = useState<ClientSchema | undefined>(
value?.client ?? undefined
);
const form = useForm<Partial<ClientSchema>>({
initialValues,
validate: {
details: {
phoneNumber: phoneNumber => {
if (!phoneNumber || phoneNumber === "+7 ") return false;
return (
!phone(phoneNumber || "", {
country: "",
strictDetection: false,
validateMobilePrefix: false,
}).isValid && "Неверно указан номер телефона"
);
},
inn: (inn: string | undefined | null) =>
inn && !isValidInn(inn) ? "Некорректный ИНН" : null,
},
},
});
useEffect(() => {
const data = value?.client ?? {};
form.setValues(data);
form.resetDirty();
}, [value]);
const isEditorDisabled = useMemo(
() =>
!client ||
client?.id !== value?.client?.id ||
client?.isDeleted === true,
[client, value.client?.id]
);
const submitClientSelection = () => {
if (!(value && client)) return;
onChange({ ...value, client });
};
return {
form,
client,
setClient,
isEditorDisabled,
submitClientSelection,
deal: value,
};
};
export const [ClientTabContextProvider, useClientTabContext] = makeContext<
ClientTabContextState,
Props
>(useClientTabContextState, "ClientTab");

View File

@ -1,3 +1,4 @@
export { default as CommonServicesTab } from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/CommonServicesTab";
export { default as FulfillmentBaseTab } from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/FulfillmentBaseTab";
export { default as ProductsTab } from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/ProductsTab";
export { default as ClientsTab } from "@/modules/dealModularEditorTabs/Clients/shared/ClientsTab";

View File

@ -1,16 +1,19 @@
import {
IconColumns,
IconBox,
IconColumns,
IconUsers,
} from "@tabler/icons-react";
import {
CommonServicesTab,
ProductsTab,
CommonServicesTab,
FulfillmentBaseTab,
ClientsTab,
} from "./dealModularEditorTabs";
import ModulesType from "./types";
export enum ModuleNames {
FULFILLMENT_BASE = "fulfillment_base",
CLIENTS = "clients",
}
const MODULES: ModulesType = {
@ -20,13 +23,6 @@ const MODULES: ModulesType = {
label: "Фулфиллмент",
},
tabs: [
{
label: "Услуги",
key: "common_services",
icon: <IconColumns />,
device: "mobile",
tab: (props: any) => <CommonServicesTab {...props} key={"common_services"} />,
},
{
label: "Товары",
key: "products",
@ -34,6 +30,13 @@ const MODULES: ModulesType = {
device: "mobile",
tab: (props: any) => <ProductsTab {...props} key={"products"} />,
},
{
label: "Услуги",
key: "common_services",
icon: <IconColumns />,
device: "mobile",
tab: (props: any) => <CommonServicesTab {...props} key={"common_services"} />,
},
{
label: "Фулфиллмент",
key: "fulfillment_base",
@ -43,6 +46,21 @@ const MODULES: ModulesType = {
},
]
},
[ModuleNames.CLIENTS]: {
info: {
key: "clients",
label: "Клиенты",
},
tabs: [
{
label: "Клиенты",
key: "clients",
icon: <IconUsers />,
device: "both",
tab: (props: any) => <ClientsTab {...props} key={"clients"} />,
},
]
},
};
export default MODULES;

View File

@ -72,5 +72,10 @@ export const theme = createTheme({
radius,
},
},
Input: {
defaultProps: {
radius,
},
},
},
});

View File

@ -6200,6 +6200,7 @@ __metadata:
libphonenumber-js: "npm:^1.12.10"
mantine-datatable: "npm:^8.2.0"
next: "npm:15.4.7"
phone: "npm:^3.1.67"
postcss: "npm:^8.5.6"
postcss-preset-mantine: "npm:1.17.0"
postcss-simple-vars: "npm:^7.0.1"
@ -11212,6 +11213,13 @@ __metadata:
languageName: node
linkType: hard
"phone@npm:^3.1.67":
version: 3.1.67
resolution: "phone@npm:3.1.67"
checksum: 10c0/468ac0410a9f158a0dc3860cf191cc34dd2f062221688ebc9b07b6e147be3591e1028f1677cdbe3437c845bbd1c52942db5546f061b4ec8c0e2d87edb7fa0744
languageName: node
linkType: hard
"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1":
version: 1.1.1
resolution: "picocolors@npm:1.1.1"