feat: clients page
This commit is contained in:
@ -0,0 +1,23 @@
|
||||
import { FC } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/useClientsActions";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
const ClientDesktopHeader: FC = () => {
|
||||
const { search, setSearch } = useClientsContext();
|
||||
const { onCreateClick } = useClientsActions();
|
||||
|
||||
return (
|
||||
<Group gap={"xs"}>
|
||||
<InlineButton onClick={onCreateClick}>Создать клиента</InlineButton>
|
||||
<TextInput
|
||||
placeholder={"Поиск"}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDesktopHeader;
|
||||
@ -0,0 +1,31 @@
|
||||
import { FC } from "react";
|
||||
import { Flex, TextInput } from "@mantine/core";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/useClientsActions";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
const ClientMobileHeader: FC = () => {
|
||||
const { search, setSearch } = useClientsContext();
|
||||
const { onCreateClick } = useClientsActions();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
px={"xs"}
|
||||
pt={"xs"}>
|
||||
<InlineButton
|
||||
w={"100%"}
|
||||
onClick={onCreateClick}>
|
||||
Создать клиента
|
||||
</InlineButton>
|
||||
<TextInput
|
||||
w={"100%"}
|
||||
placeholder={"Поиск"}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientMobileHeader;
|
||||
@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { useClientsTableColumns } from "@/app/clients/components/shared/ClientsTable/columns";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/useClientsActions";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const ClientsTable: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { clientsCrud, clients } = useClientsContext();
|
||||
const { onUpdateClick } = useClientsActions();
|
||||
|
||||
const columns = useClientsTableColumns({
|
||||
onDelete: clientsCrud.onDelete,
|
||||
onChange: onUpdateClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
records={clients}
|
||||
columns={columns}
|
||||
verticalSpacing={"xs"}
|
||||
mx={isMobile ? "xs" : 0}
|
||||
groups={undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsTable;
|
||||
58
src/app/clients/components/shared/ClientsTable/columns.tsx
Normal file
58
src/app/clients/components/shared/ClientsTable/columns.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { Center } from "@mantine/core";
|
||||
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
onChange: (client: ClientSchema) => void;
|
||||
onDelete: (client: ClientSchema) => void;
|
||||
};
|
||||
|
||||
export const useClientsTableColumns = ({ onChange, onDelete }: Props) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: client => (
|
||||
<UpdateDeleteTableActions
|
||||
onDelete={() => onDelete(client)}
|
||||
onChange={() => onChange(client)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Имя",
|
||||
},
|
||||
{
|
||||
accessor: "details.telegram",
|
||||
title: "Телеграм",
|
||||
},
|
||||
{
|
||||
accessor: "details.email",
|
||||
title: "Почта",
|
||||
},
|
||||
{
|
||||
accessor: "details.phoneNumber",
|
||||
title: "Телефон",
|
||||
},
|
||||
{
|
||||
accessor: "details.inn",
|
||||
title: "ИНН",
|
||||
},
|
||||
{
|
||||
accessor: "companyName",
|
||||
title: "Название компании",
|
||||
},
|
||||
{
|
||||
accessor: "comment",
|
||||
title: "Комментарий",
|
||||
},
|
||||
] as DataTableColumn<ClientSchema>[],
|
||||
[onChange, onDelete]
|
||||
);
|
||||
};
|
||||
37
src/app/clients/components/shared/PageBody/PageBody.tsx
Normal file
37
src/app/clients/components/shared/PageBody/PageBody.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import ClientDesktopHeader from "@/app/clients/components/desktop/ClientDesktopHeader/ClientDesktopHeader";
|
||||
import ClientsTable from "@/app/clients/components/shared/ClientsTable/ClientsTable";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import ClientMobileHeader from "@/app/clients/components/mobile/ClientMobileHeader/ClientMobileHeader";
|
||||
|
||||
const PageBody: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Stack h={"100%"}>
|
||||
{!isMobile && (
|
||||
<PageBlock>
|
||||
<ClientDesktopHeader />
|
||||
</PageBlock>
|
||||
)}
|
||||
<PageBlock
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
fullScreenMobile>
|
||||
<Stack
|
||||
gap={"xs"}
|
||||
h={"100%"}>
|
||||
{isMobile && <ClientMobileHeader />}
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
<ClientsTable />
|
||||
</div>
|
||||
</Stack>
|
||||
</PageBlock>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
39
src/app/clients/contexts/ClientsContext.tsx
Normal file
39
src/app/clients/contexts/ClientsContext.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
ClientsCrud,
|
||||
useClientsCrud,
|
||||
} from "@/app/clients/hooks/useClientsCrud";
|
||||
import useClientsFilter from "@/app/clients/hooks/useClientsFilter";
|
||||
import useClientsList from "@/app/clients/hooks/useClientsList";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
import makeContext from "@/lib/contextFactory/contextFactory";
|
||||
|
||||
type ClientsContextState = {
|
||||
clients: ClientSchema[];
|
||||
refetchClients: () => void;
|
||||
search: string;
|
||||
setSearch: Dispatch<SetStateAction<string>>;
|
||||
clientsCrud: ClientsCrud;
|
||||
};
|
||||
|
||||
const useClientsContextState = (): ClientsContextState => {
|
||||
const clientsList = useClientsList();
|
||||
|
||||
const { filteredClients, search, setSearch } =
|
||||
useClientsFilter(clientsList);
|
||||
|
||||
const clientsCrud = useClientsCrud(clientsList);
|
||||
|
||||
return {
|
||||
clients: filteredClients,
|
||||
refetchClients: clientsList.refetch,
|
||||
search,
|
||||
setSearch,
|
||||
clientsCrud,
|
||||
};
|
||||
};
|
||||
|
||||
export const [ClientsContextProvider, useClientsContext] =
|
||||
makeContext<ClientsContextState>(useClientsContextState, "Clients");
|
||||
37
src/app/clients/hooks/useClientsActions.tsx
Normal file
37
src/app/clients/hooks/useClientsActions.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
const useClientsActions = () => {
|
||||
const { clientsCrud } = useClientsContext();
|
||||
|
||||
const onCreateClick = () => {
|
||||
modals.openContextModal({
|
||||
modal: "clientEditorModal",
|
||||
title: "Создание клиента",
|
||||
innerProps: {
|
||||
onCreate: clientsCrud.onCreate,
|
||||
isEditing: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateClick = (client: ClientSchema) => {
|
||||
modals.openContextModal({
|
||||
modal: "clientEditorModal",
|
||||
title: "Редактирование клиента",
|
||||
innerProps: {
|
||||
onChange: updates => clientsCrud.onUpdate(client.id, updates),
|
||||
entity: client,
|
||||
isEditing: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onCreateClick,
|
||||
onUpdateClick,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClientsActions;
|
||||
45
src/app/clients/hooks/useClientsCrud.tsx
Normal file
45
src/app/clients/hooks/useClientsCrud.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
|
||||
import {
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema,
|
||||
} from "@/lib/client";
|
||||
import {
|
||||
createClientMutation,
|
||||
deleteClientMutation,
|
||||
updateClientMutation,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type UseClientsProps = {
|
||||
queryKey: any[];
|
||||
};
|
||||
|
||||
export type ClientsCrud = {
|
||||
onCreate: (client: CreateClientSchema) => void;
|
||||
onUpdate: (clientId: number, client: UpdateClientSchema) => void;
|
||||
onDelete: (client: ClientSchema) => void;
|
||||
};
|
||||
|
||||
export const useClientsCrud = ({ queryKey }: UseClientsProps): ClientsCrud => {
|
||||
return useCrudOperations<
|
||||
ClientSchema,
|
||||
UpdateClientSchema,
|
||||
CreateClientSchema
|
||||
>({
|
||||
key: "getClients",
|
||||
queryKey,
|
||||
mutations: {
|
||||
create: createClientMutation(),
|
||||
update: updateClientMutation(),
|
||||
delete: deleteClientMutation(),
|
||||
},
|
||||
getUpdateEntity: (old, update) => ({
|
||||
...old,
|
||||
details: update.details ?? old.details,
|
||||
name: update.name ?? old.name,
|
||||
companyName: update.companyName ?? old.companyName,
|
||||
comment: update.comment ?? old.comment,
|
||||
}),
|
||||
getDeleteConfirmTitle: () => "Удаление клиента",
|
||||
});
|
||||
};
|
||||
46
src/app/clients/hooks/useClientsFilter.tsx
Normal file
46
src/app/clients/hooks/useClientsFilter.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
clients: ClientSchema[];
|
||||
};
|
||||
|
||||
const useClientsFilter = ({ clients }: Props) => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 400);
|
||||
const [filteredClients, setFilteredClients] = useState<ClientSchema[]>([]);
|
||||
|
||||
const filterClients = () => {
|
||||
if (debouncedSearch.length === 0) {
|
||||
setFilteredClients(clients);
|
||||
return;
|
||||
}
|
||||
|
||||
const loweredSearch = debouncedSearch.toLowerCase();
|
||||
const filtered = clients.filter(
|
||||
client =>
|
||||
client.name.toLowerCase().includes(loweredSearch) ||
|
||||
client.details?.inn?.includes(loweredSearch) ||
|
||||
client.details?.email?.toLowerCase().includes(loweredSearch) ||
|
||||
client.details?.telegram
|
||||
?.toLowerCase()
|
||||
.includes(loweredSearch) ||
|
||||
client.details?.phoneNumber?.includes(loweredSearch) ||
|
||||
client.companyName.toLowerCase().includes(loweredSearch)
|
||||
);
|
||||
setFilteredClients(filtered);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
filterClients();
|
||||
}, [debouncedSearch, clients]);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
filteredClients,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClientsFilter;
|
||||
17
src/app/clients/hooks/useClientsList.tsx
Normal file
17
src/app/clients/hooks/useClientsList.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getClientsOptions,
|
||||
getClientsQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
const useClientsList = () => {
|
||||
const { data, refetch } = useQuery(getClientsOptions());
|
||||
const clients = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const queryKey = getClientsQueryKey();
|
||||
|
||||
return { clients, refetch, queryKey };
|
||||
};
|
||||
|
||||
export default useClientsList;
|
||||
105
src/app/clients/modals/ClientFormModal/ClientFormModal.tsx
Normal file
105
src/app/clients/modals/ClientFormModal/ClientFormModal.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Fieldset, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { ContextModalProps } from "@mantine/modals";
|
||||
import isValidInn from "@/app/clients/utils/isValidInn";
|
||||
import {
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema,
|
||||
} from "@/lib/client";
|
||||
import BaseFormModal, {
|
||||
CreateEditFormProps,
|
||||
} from "@/modals/base/BaseFormModal/BaseFormModal";
|
||||
|
||||
type Props = CreateEditFormProps<
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema
|
||||
>;
|
||||
|
||||
const ClientEditorModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<Props>) => {
|
||||
const initialValues = innerProps.isEditing
|
||||
? innerProps.entity
|
||||
: ({
|
||||
name: "",
|
||||
companyName: "",
|
||||
details: {
|
||||
telegram: "",
|
||||
phoneNumber: "",
|
||||
email: "",
|
||||
inn: "",
|
||||
},
|
||||
comment: "",
|
||||
} as CreateClientSchema);
|
||||
|
||||
const form = useForm({
|
||||
initialValues,
|
||||
validate: {
|
||||
name: name =>
|
||||
(!name || name.trim() === "") &&
|
||||
"Необходимо ввести название клиента",
|
||||
details: {
|
||||
inn: inn => inn.length > 0 && !isValidInn(inn) && "Некорректный ИНН",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseFormModal
|
||||
{...innerProps}
|
||||
closeOnSubmit
|
||||
form={form}
|
||||
onClose={() => context.closeContextModal(id)}>
|
||||
<Fieldset legend={"Основная информация"}>
|
||||
<TextInput
|
||||
required
|
||||
label={"Название клиента"}
|
||||
placeholder={"Введите название клиента"}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
</Fieldset>
|
||||
<Fieldset legend={"Дополнительная информация"}>
|
||||
<Stack gap={"xs"}>
|
||||
<TextInput
|
||||
label={"Телеграм"}
|
||||
placeholder={"Введите телеграм"}
|
||||
{...form.getInputProps("details.telegram")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Номер телефона"}
|
||||
placeholder={"Введите номер телефона"}
|
||||
{...form.getInputProps("details.phoneNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Почта"}
|
||||
placeholder={"Введите почту"}
|
||||
{...form.getInputProps("details.email")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"ИНН"}
|
||||
placeholder={"Введите ИНН"}
|
||||
{...form.getInputProps("details.inn")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Название компании"}
|
||||
placeholder={"Введите название компании"}
|
||||
{...form.getInputProps("companyName")}
|
||||
/>
|
||||
<Textarea
|
||||
label={"Комментарий"}
|
||||
placeholder={"Введите комментарий"}
|
||||
{...form.getInputProps("comment")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</BaseFormModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientEditorModal;
|
||||
22
src/app/clients/page.tsx
Normal file
22
src/app/clients/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ClientsContextProvider } from "@/app/clients/contexts/ClientsContext";
|
||||
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||
import PageBody from "@/app/clients/components/shared/PageBody/PageBody";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="50vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}>
|
||||
<PageContainer>
|
||||
<ClientsContextProvider>
|
||||
<PageBody />
|
||||
</ClientsContextProvider>
|
||||
</PageContainer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
5
src/app/clients/utils/isValidInn.ts
Normal file
5
src/app/clients/utils/isValidInn.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const isValidInn = (inn: string | null | undefined) => {
|
||||
return inn && inn.match(/^(\d{12}|\d{10})$/);
|
||||
};
|
||||
|
||||
export default isValidInn;
|
||||
Reference in New Issue
Block a user