feat: attributes in cards and projects

This commit is contained in:
2025-02-27 18:34:15 +04:00
parent 8083bdf3d0
commit d37dce7980
75 changed files with 1674 additions and 281 deletions

View File

@@ -25,6 +25,7 @@ export type { BarcodeTemplateSchema } from './models/BarcodeTemplateSchema';
export type { BarcodeTemplateSizeSchema } from './models/BarcodeTemplateSizeSchema';
export type { BarcodeTemplateUpdateRequest } from './models/BarcodeTemplateUpdateRequest';
export type { BarcodeTemplateUpdateResponse } from './models/BarcodeTemplateUpdateResponse';
export type { BaseAttributeSchema } from './models/BaseAttributeSchema';
export type { BaseBoardSchema } from './models/BaseBoardSchema';
export type { BaseEnumListSchema } from './models/BaseEnumListSchema';
export type { BaseEnumSchema } from './models/BaseEnumSchema';
@@ -123,6 +124,8 @@ export type { ClientSchema } from './models/ClientSchema';
export type { ClientUpdateDetailsRequest } from './models/ClientUpdateDetailsRequest';
export type { ClientUpdateRequest } from './models/ClientUpdateRequest';
export type { ClientUpdateResponse } from './models/ClientUpdateResponse';
export type { CreateAttributeRequest } from './models/CreateAttributeRequest';
export type { CreateAttributeResponse } from './models/CreateAttributeResponse';
export type { CreateBarcodeTemplateAttributeRequest } from './models/CreateBarcodeTemplateAttributeRequest';
export type { CreateBarcodeTemplateAttributeResponse } from './models/CreateBarcodeTemplateAttributeResponse';
export type { CreateBoardRequest } from './models/CreateBoardRequest';
@@ -168,6 +171,7 @@ export type { CreateTaskResponse } from './models/CreateTaskResponse';
export type { CreateTransactionTagRequest } from './models/CreateTransactionTagRequest';
export type { CreateUserRequest } from './models/CreateUserRequest';
export type { CreateUserResponse } from './models/CreateUserResponse';
export type { DeleteAttributeResponse } from './models/DeleteAttributeResponse';
export type { DeleteBoardResponse } from './models/DeleteBoardResponse';
export type { DeleteBoxResponse } from './models/DeleteBoxResponse';
export type { DeleteDepartmentResponse } from './models/DeleteDepartmentResponse';
@@ -208,6 +212,7 @@ export type { GetAllBarcodeTemplateAttributesResponse } from './models/GetAllBar
export type { GetAllBarcodeTemplateSizesResponse } from './models/GetAllBarcodeTemplateSizesResponse';
export type { GetAllBarcodeTemplatesResponse } from './models/GetAllBarcodeTemplatesResponse';
export type { GetAllBaseMarketplacesResponse } from './models/GetAllBaseMarketplacesResponse';
export type { GetAllModulesResponse } from './models/GetAllModulesResponse';
export type { GetAllPayRatesResponse } from './models/GetAllPayRatesResponse';
export type { GetAllPayrollSchemeResponse } from './models/GetAllPayrollSchemeResponse';
export type { GetAllPositionsResponse } from './models/GetAllPositionsResponse';
@@ -218,6 +223,8 @@ export type { GetAllTransactionsRequest } from './models/GetAllTransactionsReque
export type { GetAllTransactionsResponse } from './models/GetAllTransactionsResponse';
export type { GetAllTransactionTagsResponse } from './models/GetAllTransactionTagsResponse';
export type { GetAllUsersResponse } from './models/GetAllUsersResponse';
export type { GetAttributesResponse } from './models/GetAttributesResponse';
export type { GetAttributeTypesResponse } from './models/GetAttributeTypesResponse';
export type { GetAvailableEmployeesToAssignResponse } from './models/GetAvailableEmployeesToAssignResponse';
export type { GetAvailableUsersForDepartmentSectionResponse } from './models/GetAvailableUsersForDepartmentSectionResponse';
export type { GetBarcodeTemplateByIdRequest } from './models/GetBarcodeTemplateByIdRequest';
@@ -339,6 +346,10 @@ export type { TimeTrackingData } from './models/TimeTrackingData';
export type { TimeTrackingRecord } from './models/TimeTrackingRecord';
export type { TransactionSchemaBase } from './models/TransactionSchemaBase';
export type { TransactionTagSchema } from './models/TransactionTagSchema';
export type { UpdateAttributeRequest } from './models/UpdateAttributeRequest';
export type { UpdateAttributeResponse } from './models/UpdateAttributeResponse';
export type { UpdateAttributesRequest } from './models/UpdateAttributesRequest';
export type { UpdateAttributesResponse } from './models/UpdateAttributesResponse';
export type { UpdateBoardOrderRequest } from './models/UpdateBoardOrderRequest';
export type { UpdateBoardOrderResponse } from './models/UpdateBoardOrderResponse';
export type { UpdateBoardRequest } from './models/UpdateBoardRequest';
@@ -352,6 +363,8 @@ export type { UpdateDepartmentSectionRequest } from './models/UpdateDepartmentSe
export type { UpdateDepartmentSectionResponse } from './models/UpdateDepartmentSectionResponse';
export type { UpdateMarketplaceRequest } from './models/UpdateMarketplaceRequest';
export type { UpdateMarketplaceResponse } from './models/UpdateMarketplaceResponse';
export type { UpdateModulesRequest } from './models/UpdateModulesRequest';
export type { UpdateModulesResponse } from './models/UpdateModulesResponse';
export type { UpdatePayRateRequest } from './models/UpdatePayRateRequest';
export type { UpdatePayRateResponse } from './models/UpdatePayRateResponse';
export type { UpdatePlanningWorkShiftRequest } from './models/UpdatePlanningWorkShiftRequest';
@@ -394,6 +407,7 @@ export type { ValidationError } from './models/ValidationError';
export type { WorkShiftRowSchema } from './models/WorkShiftRowSchema';
export type { WorkShiftSchema } from './models/WorkShiftSchema';
export { AttributeService } from './services/AttributeService';
export { AuthService } from './services/AuthService';
export { BarcodeService } from './services/BarcodeService';
export { BillingService } from './services/BillingService';

View File

@@ -4,12 +4,15 @@
/* eslint-disable */
import type { AttributeTypeSchema } from './AttributeTypeSchema';
export type AttributeSchema = {
id: number;
label: string;
name: string;
isApplicableToGroup: boolean;
isNullable: boolean;
defaultValue: (boolean | number | string | null);
typeId: number;
description: string;
id: number;
type: AttributeTypeSchema;
isDeleted: boolean;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type BaseAttributeSchema = {
label: string;
name: string;
isApplicableToGroup: boolean;
isNullable: boolean;
defaultValue: (boolean | number | string | null);
typeId: number;
description: string;
};

View File

@@ -14,5 +14,6 @@ export type CardGeneralInfoSchema = {
boardId: number;
statusId: number;
isServicesProfitAccounted: boolean;
clientId: (number | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { BaseAttributeSchema } from './BaseAttributeSchema';
export type CreateAttributeRequest = {
attribute: BaseAttributeSchema;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateAttributeResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteAttributeResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ModuleSchema } from './ModuleSchema';
export type GetAllModulesResponse = {
modules: Array<ModuleSchema>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AttributeTypeSchema } from './AttributeTypeSchema';
export type GetAttributeTypesResponse = {
types: Array<AttributeTypeSchema>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AttributeSchema } from './AttributeSchema';
export type GetAttributesResponse = {
attributes: Array<AttributeSchema>;
};

View File

@@ -5,6 +5,7 @@
export type ModuleSchema = {
id: number;
key: string;
label: string;
isDeleted: boolean;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AttributeSchema } from './AttributeSchema';
export type UpdateAttributeRequest = {
attribute: AttributeSchema;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateAttributeResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateAttributesRequest = {
projectId: number;
attributeIds: Array<number>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateAttributesResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateModulesRequest = {
projectId: number;
moduleIds: Array<number>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateModulesResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,99 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CreateAttributeRequest } from '../models/CreateAttributeRequest';
import type { CreateAttributeResponse } from '../models/CreateAttributeResponse';
import type { DeleteAttributeResponse } from '../models/DeleteAttributeResponse';
import type { GetAttributesResponse } from '../models/GetAttributesResponse';
import type { GetAttributeTypesResponse } from '../models/GetAttributeTypesResponse';
import type { UpdateAttributeRequest } from '../models/UpdateAttributeRequest';
import type { UpdateAttributeResponse } from '../models/UpdateAttributeResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AttributeService {
/**
* Get All
* @returns GetAttributesResponse Successful Response
* @throws ApiError
*/
public static getAll(): CancelablePromise<GetAttributesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/attribute/',
});
}
/**
* Create
* @returns CreateAttributeResponse Successful Response
* @throws ApiError
*/
public static create({
requestBody,
}: {
requestBody: CreateAttributeRequest,
}): CancelablePromise<CreateAttributeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/attribute/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Update
* @returns UpdateAttributeResponse Successful Response
* @throws ApiError
*/
public static update({
requestBody,
}: {
requestBody: UpdateAttributeRequest,
}): CancelablePromise<UpdateAttributeResponse> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/attribute/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Types
* @returns GetAttributeTypesResponse Successful Response
* @throws ApiError
*/
public static getTypes(): CancelablePromise<GetAttributeTypesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/attribute/types',
});
}
/**
* Delete
* @returns DeleteAttributeResponse Successful Response
* @throws ApiError
*/
public static delete({
attributeId,
}: {
attributeId: number,
}): CancelablePromise<DeleteAttributeResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/attribute/{attribute_id}',
path: {
'attribute_id': attributeId,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -5,7 +5,12 @@
import type { CreateProjectRequest } from '../models/CreateProjectRequest';
import type { CreateProjectResponse } from '../models/CreateProjectResponse';
import type { DeleteProjectResponse } from '../models/DeleteProjectResponse';
import type { GetAllModulesResponse } from '../models/GetAllModulesResponse';
import type { GetProjectsResponse } from '../models/GetProjectsResponse';
import type { UpdateAttributesRequest } from '../models/UpdateAttributesRequest';
import type { UpdateAttributesResponse } from '../models/UpdateAttributesResponse';
import type { UpdateModulesRequest } from '../models/UpdateModulesRequest';
import type { UpdateModulesResponse } from '../models/UpdateModulesResponse';
import type { UpdateProjectRequest } from '../models/UpdateProjectRequest';
import type { UpdateProjectResponse } from '../models/UpdateProjectResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
@@ -84,4 +89,55 @@ export class ProjectService {
},
});
}
/**
* Get All Modules
* @returns GetAllModulesResponse Successful Response
* @throws ApiError
*/
public static getAllModules(): CancelablePromise<GetAllModulesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/project/modules',
});
}
/**
* Update Project Modules
* @returns UpdateModulesResponse Successful Response
* @throws ApiError
*/
public static updateProjectModules({
requestBody,
}: {
requestBody: UpdateModulesRequest,
}): CancelablePromise<UpdateModulesResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/project/modules',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Project Attributes
* @returns UpdateAttributesResponse Successful Response
* @throws ApiError
*/
public static updateProjectAttributes({
requestBody,
}: {
requestBody: UpdateAttributesRequest,
}): CancelablePromise<UpdateAttributesResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/project/attributes',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -1,8 +1,7 @@
import { AttributeSchema } from "../../../client";
import { Checkbox, Tooltip } from "@mantine/core";
import { Checkbox, NumberInput, TextInput } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { ReactNode } from "react";
import { DatePickerInput } from "@mantine/dates";
import { DatePickerInput, DateTimePicker } from "@mantine/dates";
import { CardGeneralFormType } from "../../../pages/CardsPage/tabs/GeneralTab/GeneralTab.tsx";
type Props = {
@@ -11,41 +10,65 @@ type Props = {
}
const CardAttributeField = ({ attribute, form }: Props) => {
const tooltipLabel = attribute.isApplicableToGroup
? "Применяется ко всей группе"
: "Применяется только к данной сделке";
const type = attribute.type.type;
let component: ReactNode;
const getDateValue = (): Date | null => {
const value = form.getInputProps(attribute.name).value;
if (!value) return null;
return new Date(value);
};
if (attribute.type.type === "bool") {
component = (
const labelData = {
label: attribute.label,
description: attribute.description,
};
if (type === "bool") {
return (
<Checkbox
label={attribute.label}
{...labelData}
{...form.getInputProps(attribute.name, { type: "checkbox" })}
// defaultChecked={form.getInputProps(attribute.name, { type: "checkbox"}).value}
/>
);
}
else if (attribute.type.type === "datetime" || attribute.type.type === "date") {
console.log("value = ");
console.log(form.getInputProps(attribute.name).value);
console.log("type = ")
console.log(typeof form.getInputProps(attribute.name).value);
component = (
} else if (type === "date") {
return (
<DatePickerInput
label={attribute.label}
{...labelData}
{...form.getInputProps(attribute.name)}
value={getDateValue()}
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY"
/>
);
} else if (type === "datetime") {
return (
<DateTimePicker
{...labelData}
{...form.getInputProps(attribute.name)}
value={getDateValue()}
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY HH:mm"
/>
);
} else if (type === "str") {
return (
<TextInput
{...labelData}
{...form.getInputProps(attribute.name)}
value={form.getInputProps(attribute.name).value ?? ""}
/>
);
} else if (type === "int" || type === "float") {
return (
<NumberInput
allowDecimal={type === "float"}
{...labelData}
{...form.getInputProps(attribute.name)}
clearable={attribute.isNullable}
/>
);
}
return (
<Tooltip label={tooltipLabel}>
{component}
</Tooltip>
);
};
export default CardAttributeField;

View File

@@ -28,7 +28,9 @@ const useCardsDnd = ({
cardId,
},
}).then(({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) {
notifications.guess(ok, { message });
}
});
};
@@ -144,7 +146,7 @@ const useCardsDnd = ({
});
};
const onDealDragEnd = async (result: DropResult) => {
const onCardDragEnd = async (result: DropResult) => {
if (result.combine) {
return onCombine(result);
}
@@ -206,7 +208,7 @@ const useCardsDnd = ({
return {
summaries,
onDealDragEnd,
onCardDragEnd,
};
};

View File

@@ -1,7 +1,7 @@
import styles from "../../../../pages/CardsPage/ui/CardsPage.module.css";
import { Divider, Text, Title } from "@mantine/core";
import getColumnColor from "../../Cards/CardsDndColumn/utils/getColumnColor.ts";
import { CardSummary, StatusSchema } from "../../../../client";
import { BoardSchema, CardSummary, StatusSchema } from "../../../../client";
import { getPluralForm } from "../../../../shared/lib/utils.ts";
import { sum } from "lodash";
import { Draggable, Droppable } from "@hello-pangea/dnd";
@@ -9,18 +9,21 @@ import DragState from "../../../../pages/CardsPage/enums/DragState.ts";
import { useContextMenu } from "mantine-contextmenu";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import useStatus from "./hooks/useStatus.tsx";
import isModuleInProject, { Modules } from "../../../../pages/CardsPage/utils/isModuleInProject.ts";
type Props = {
status: StatusSchema;
board: BoardSchema | null;
index: number;
summaries: CardSummary[];
dragState: DragState;
refetch: () => void;
}
const Status = ({ summaries, status, dragState, index, refetch }: Props) => {
const isDropDisabled = dragState === DragState.DRAG_DEAL;
const Status = ({ summaries, status, board, dragState, index, refetch }: Props) => {
const isDropDisabled = dragState === DragState.DRAG_CARD;
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, board?.project);
const {
onEditStatusClick,
@@ -34,7 +37,10 @@ const Status = ({ summaries, status, dragState, index, refetch }: Props) => {
"сделки",
"сделок",
);
return `${summaries.length} ${pluralForm}: ${sum(summaries.map(summary => summary.totalPrice)).toLocaleString("ru-RU")}`;
const priceLabel = isServicesAndProductsIncluded
? `: ${sum(summaries.map(summary => summary.totalPrice)).toLocaleString("ru-RU")}`
: "";
return `${summaries.length} ${pluralForm}` + priceLabel;
};
const { showContextMenu } = useContextMenu();

View File

@@ -44,6 +44,7 @@ const Statuses = ({
summaries={filteredSummaries}
index={index}
status={status}
board={selectedBoard}
dragState={dragState}
refetch={refetchBoards}
/>

View File

@@ -0,0 +1,11 @@
import { AttributeService } from "../client";
import ObjectList from "./objectList.tsx";
const useAttributesList = () =>
ObjectList({
queryFn: AttributeService.getAll,
getObjectsFn: response => response.attributes,
queryKey: "getAllAttributes",
});
export default useAttributesList;

View File

@@ -39,7 +39,7 @@ function usePollingEffect(
// Immediately invoke the polling callback when the effect runs
pollingCallback();
// Clean up function to clear the timeout if the component unmounts or dependencies change
// Clean up function to clear the timeout if the components unmounts or dependencies change
return () => {
stopped = true; // This will prevent new timeouts from being scheduled
if (timeoutIdRef.current) {

View File

@@ -28,14 +28,16 @@ import ShippingProductModal from "../pages/CardsPage/tabs/ShippingTab/modals/Shi
import DepartmentModal from "../pages/AdminPage/tabs/OrganizationalStructureTab/modals/DepartmentModal.tsx";
import AddUserToDepartmentModal
from "../pages/AdminPage/tabs/OrganizationalStructureTab/modals/AddUserToDepartmentModal.tsx";
import AssignUserModal from "../pages/CardsPage/tabs/EmployeesTab/modals/AssignUserModal.tsx";
import AssignEmployeeModal from "../pages/CardsPage/tabs/EmployeesTab/modals/AssignEmployeeModal.tsx";
import ResidualProductModal from "../pages/ResiduesPage/modals/ResidualProductModal/ResidualProductModal.tsx";
import NewReceiptModal from "../pages/ReceiptPage/components/NewReceipt/modals/NewReceiptModal.tsx";
import ReceiptModal from "../pages/ReceiptPage/components/ReceiptEditing/modals/ReceiptModal.tsx";
import SelectScannedProductModal from "../pages/ReceiptPage/modals/SelectScannedProductModal.tsx";
import ProjectsModal from "../pages/CardsPage/modals/ProjectsModal/ProjectsModal.tsx";
import BoardModal from "../pages/CardsPage/modals/BoardModal/BoardModal.tsx";
import StatusModal from "../pages/CardsPage/modals/StatusModal/StatusModal.tsx";
import AttributeModal from "../pages/CardsPage/drawers/ProjectsEditorDrawer/tabs/AttributesTab/modals/AttributeModal.tsx";
import CreateProjectModal
from "../pages/CardsPage/drawers/ProjectsEditorDrawer/tabs/ProjectsTab/modals/CreateProjectModal.tsx";
export const modals = {
enterDeadline: EnterDeadlineModal,
@@ -70,8 +72,9 @@ export const modals = {
receiptModal: ReceiptModal,
departmentModal: DepartmentModal,
addUserToDepartmentModal: AddUserToDepartmentModal,
assignUserModal: AssignUserModal,
projectsModal: ProjectsModal,
assignEmployeeModal: AssignEmployeeModal,
boardModal: BoardModal,
statusModal: StatusModal,
attributeModal: AttributeModal,
createProjectModal: CreateProjectModal,
};

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

@@ -8,16 +8,17 @@ import { UseFormReturnType } from "@mantine/form";
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 CardsTableFiltersModal from "../../modals/CardsTableFiltersModal.tsx";
import { useProjectsEditorContext } from "../../contexts/ProjectsEditorContext.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type Props = {
displayMode: DisplayMode;
setDisplayMode: React.Dispatch<React.SetStateAction<DisplayMode>>;
form: UseFormReturnType<CardsPageState>;
projects: ProjectSchema[];
refetchProjects: () => void;
}
const CardsPageHeader = ({
@@ -25,30 +26,27 @@ const CardsPageHeader = ({
setDisplayMode,
form,
projects,
refetchProjects,
}: Props) => {
const openModal = () => {
modals.openContextModal({
modal: "projectsModal",
title: "Проекты",
innerProps: {
onUpdate: refetchProjects,
},
});
};
const { openProjectsEditor } = useProjectsEditorContext();
const userRole = useSelector((state: RootState) => state.auth.role);
const isAdmin = userRole === "admin";
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={openProjectsEditor}
variant={"default"}>
<IconEdit />
</ActionIcon>
)}
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}

View File

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

View File

@@ -24,7 +24,7 @@ const CardEditDrawerStatusChangeTable = () => {
return <CardStatusChangeTable items={statusHistory} />;
};
const useDealEditDrawerState = () => {
const useCardEditDrawerState = () => {
const { selectedCard, setSelectedCard } = useCardPageContext();
return {
isVisible: selectedCard !== undefined,
@@ -33,7 +33,7 @@ const useDealEditDrawerState = () => {
};
const CardEditDrawer: FC = () => {
const { isVisible, onClose } = useDealEditDrawerState();
const { isVisible, onClose } = useCardEditDrawerState();
const queryClient = useQueryClient();
const { selectedCard } = useCardPageContext();

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 "./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 { usePrefillCardContext } from "../../contexts/PrefillCardContext.tsx";
const CardPrefillDrawer: FC = () => {
const { prefillOpened, prefillOnClose, selectedPrefillCard, setPrefillCard, prefillCard } = usePrefillCardContext();
const { data, form } = usePrefillDeal();
const { data, form } = usePrefillCard();
useEffect(() => {
if (prefillOpened) return;
@@ -33,8 +33,8 @@ const CardPrefillDrawer: 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,7 +49,7 @@ const CardPrefillDrawer: FC = () => {
{...form.getInputProps("marketplace")}
/>
</div>
<DealsTable items={data} />
<CardsTable items={data} />
<Flex direction={"row"} gap="sm">
<Button mt={10} w={"100%"} onClick={() => {
if (!selectedPrefillCard) {

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,7 +1,7 @@
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 { usePrefillCardContext } from "../../../../contexts/PrefillCardContext.tsx";
@@ -28,7 +28,7 @@ const Preview: FC = () => {
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={!selectedPrefillCard}>
@@ -37,7 +37,7 @@ const Preview: FC = () => {
{getTotalPrice().toLocaleString("ru")}
</Title>
<DealServicesTable items={selectedPrefillCard?.services} />
<CardServicesTable items={selectedPrefillCard?.services} />
<div className={styles["products-list"]}>
{selectedPrefillCard?.products.map(product => (

View File

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

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardServiceSchema } from "../../../../../../../client";
const useDealServicesTableColumns = () => {
const useCardServicesTableColumns = () => {
return useMemo<MRT_ColumnDef<CardServiceSchema>[]>(
() => [
{
@@ -27,4 +27,4 @@ const useDealServicesTableColumns = () => {
);
};
export default useDealServicesTableColumns;
export default useCardServicesTableColumns;

View File

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

View File

@@ -4,7 +4,7 @@ import { ActionIcon, Image, Radio } from "@mantine/core";
import { CardSummary } from "../../../../../../../client";
import { usePrefillCardContext } from "../../../../../contexts/PrefillCardContext.tsx";
const useDealsTableColumns = () => {
const useCardsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardSummary>[]>(
() => [
{
@@ -69,4 +69,4 @@ const useDealsTableColumns = () => {
);
};
export default useDealsTableColumns;
export default useCardsTableColumns;

View File

@@ -2,13 +2,14 @@ import { useForm } from "@mantine/form";
import { useEffect, useState } from "react";
import { BaseMarketplaceSchema } from "../../../../../client";
import { useCardSummariesFull } from "../../../hooks/useCardSummaries.tsx";
import isModuleInProject, { Modules } from "../../../utils/isModuleInProject.ts";
type State = {
idOrName: string | null;
marketplace: BaseMarketplaceSchema | null;
};
const usePrefillDeal = () => {
const usePrefillCard = () => {
const { objects } = useCardSummariesFull();
const form = useForm<State>({
initialValues: {
@@ -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

@@ -44,7 +44,7 @@ const PrefillCardsWithExcelDrawer = ({ board }: Props) => {
},
}}
>
<div className={styles["deal-container"]}>
<div className={styles["card-container"]}>
{getBody()}
</div>
</Drawer>

View File

@@ -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)}
@@ -57,7 +57,7 @@ const ProductsPreview = ({ status }: Props) => {
variant="outline"
type="submit"
>
Создать сделки
Создать
</Button>
<ParsingResultsTooltip />
</Group>

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,73 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { IconSettings, IconSubtask } from "@tabler/icons-react";
import { ReactNode } from "react";
import { motion } from "framer-motion";
import { useProjectsEditorContext } from "../../contexts/ProjectsEditorContext.tsx";
import ProjectsTab from "./tabs/ProjectsTab/ProjectsTab.tsx";
import AttributesTab from "./tabs/AttributesTab/AttributesTab.tsx";
const ProjectsEditorDrawer = () => {
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={"projects"}
flex={1}
variant={"outline"}
orientation={"vertical"}
keepMounted={false}>
<Tabs.List>
<Tabs.Tab
value={"projects"}
leftSection={<IconSettings />}>
Проекты
</Tabs.Tab>
<Tabs.Tab
value={"attributes"}
leftSection={<IconSubtask />}>
Атрибуты
</Tabs.Tab>
</Tabs.List>
{getTabPanel("projects", <ProjectsTab />)}
{getTabPanel("attributes", <AttributesTab />)}
</Tabs>
</Drawer>
);
};
export default ProjectsEditorDrawer;

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 AttributesTab = () => {
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}>
<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 AttributesTab;

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={Number(form.values.defaultValue)}
/>
)
}
return <></>;
};
export default DefaultAttributeValueInput;

View File

@@ -0,0 +1,67 @@
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";
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",
}
],
[],
);
};
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,171 @@
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 "../../../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 [isDefaultValueInputShown, setIsDefaultValueInputShown] = useState(true);
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(() => {
setIsDefaultValueInputShown(false);
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);
setIsDefaultValueInputShown(true);
}, [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 && isDefaultValueInputShown && (
<DefaultAttributeValueInput form={form} />
)}
<Textarea
label={"Описание"}
{...form.getInputProps("description")}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Stack>
</form>
);
};
export default AttributeModal;

View File

@@ -0,0 +1,43 @@
import { Group } from "@mantine/core";
import { ProjectSchema } from "../../../../../../client";
import ModulesPicker from "./components/ModulesPicker.tsx";
import { useEffect, useState } from "react";
import AttributesPicker from "./components/AttributesPicker.tsx";
import useProjects from "../../../../hooks/useProjects.tsx";
import ProjectsEditor from "./components/ProjectsEditor.tsx";
const ProjectsTab = () => {
const { projects, refetchProjects } = useProjects();
const [selectedProject, setSelectedProject] = useState<ProjectSchema | null>(null);
useEffect(() => {
if (projects.length > 0) {
if (!selectedProject) {
setSelectedProject(projects[0]);
} else {
setSelectedProject(projects.find((project) => project.id === selectedProject.id) ?? null);
}
}
}, [projects]);
return (
<Group align={"flex-start"}>
<ProjectsEditor
projects={projects}
refetchProjects={refetchProjects}
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
/>
<ModulesPicker
project={selectedProject}
refetchProjects={refetchProjects}
/>
<AttributesPicker
project={selectedProject}
refetchProjects={refetchProjects}
/>
</Group>
);
};
export default ProjectsTab;

View File

@@ -0,0 +1,79 @@
import { ProjectSchema, ProjectService } from "../../../../../../../client";
import styles from "../../../ProjectsEditorDrawer.module.css";
import { Center, rem, Stack, Title } from "@mantine/core";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import useAttributesList from "../../../../../../../hooks/useAttributesList.tsx";
import useAttributesTableColumns from "../hooks/attributesTableColumns.tsx";
import { useSet } from "@mantine/hooks";
import { useEffect, useState } from "react";
import { notifications } from "../../../../../../../shared/lib/notifications.ts";
import InlineButton from "../../../../../../../components/InlineButton/InlineButton.tsx";
import { IconCheck } from "@tabler/icons-react";
import eqSet from "../../../utils/eqSet.ts";
type Props = {
project: ProjectSchema | null;
refetchProjects: () => void;
}
const AttributesPicker = ({ project, refetchProjects }: Props) => {
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 (
<div className={styles["container-wrapper"]} style={{ flex: 2 }}>
<Stack gap={rem(10)}>
<Center>
<Title order={4}>Атрибуты проекта</Title>
</Center>
<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>
</div>
);
};
export default AttributesPicker;

View File

@@ -0,0 +1,78 @@
import { ProjectSchema, ProjectService } from "../../../../../../../client";
import useModulesList from "../hooks/useModulesList.tsx";
import styles from "../../../ProjectsEditorDrawer.module.css";
import { useSet } from "@mantine/hooks";
import useModulesTableColumns from "../hooks/modulesTableColumns.tsx";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { Center, rem, Stack, Title } from "@mantine/core";
import { useEffect, useState } from "react";
import InlineButton from "../../../../../../../components/InlineButton/InlineButton.tsx";
import { IconCheck } from "@tabler/icons-react";
import { notifications } from "../../../../../../../shared/lib/notifications.ts";
import eqSet from "../../../utils/eqSet.ts";
type Props = {
project: ProjectSchema | null;
refetchProjects: () => void;
}
const ModulesPicker = ({ project, refetchProjects }: Props) => {
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 (
<div className={styles["container-wrapper"]}>
<Stack gap={rem(10)}>
<Center>
<Title order={4}>Модули проекта</Title>
</Center>
<BaseTable
data={modules}
columns={columns}
restProps={{
enableSorting: false,
enableColumnActions: false,
}}
/>
{!eqSet(selectedModules, defaultSelectedModules) && (
<InlineButton onClick={updateProjectModules}>
<IconCheck />
Сохранить
</InlineButton>
)}
</Stack>
</div>
);
};
export default ModulesPicker;

View File

@@ -0,0 +1,95 @@
import styles from "../../../ProjectsEditorDrawer.module.css";
import { ActionIcon, Center, Flex, rem, Stack, Title, Tooltip } from "@mantine/core";
import InlineButton from "../../../../../../../components/InlineButton/InlineButton.tsx";
import { IconCheck, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { MRT_TableOptions } from "mantine-react-table";
import { FullProjectSchema, ProjectSchema } from "../../../../../../../client";
import useProjectsTableColumns from "../hooks/projectsTableColumns.tsx";
import useProjectsTab from "../hooks/useProjectsTab.tsx";
import React from "react";
type Props = {
projects: ProjectSchema[];
refetchProjects: () => void;
selectedProject: ProjectSchema | null;
setSelectedProject: React.Dispatch<React.SetStateAction<ProjectSchema | null>>;
}
const ProjectsEditor = ({
projects,
refetchProjects,
selectedProject,
setSelectedProject,
}: Props) => {
const {
editingProjects,
handleEditClick,
handleDeleteClick,
handleCreateClick,
} = useProjectsTab({
refetchProjects,
});
const columns = useProjectsTableColumns({ editingProjects, selectedProject, setSelectedProject });
return (
<div className={styles["container-wrapper"]} style={{ flex: 1.5 }}>
<Stack gap={rem(10)}>
<Center>
<Title order={4}>Проекты</Title>
</Center>
<BaseTable
data={projects}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
positionActionsColumn: "last",
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<FullProjectSchema>
}
/>
<InlineButton
variant={"default"}
onClick={handleCreateClick}
style={{ border: "dashed var(--item-border-size) var(--mantine-color-default-border)" }}
>
<IconPlus />
Добавить
</InlineButton>
</Stack>
</div>
);
};
export default ProjectsEditor;

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { AttributeSchema } from "../../../../../../../client";
import { Box, Center, Checkbox, rem, Text, Tooltip } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import defaultValueToStr from "../../../utils/defaultValueToStr.ts";
type Props = {
selectedAttributes: Set<number>;
}
const useAttributesTableColumns = ({ selectedAttributes }: Props) => {
return useMemo<MRT_ColumnDef<AttributeSchema>[]>(
() => [
{
header: "Название",
accessorKey: "label",
size: 25,
},
{
header: "Тип",
accessorKey: "type.name",
size: 25,
},
{
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: " ",
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,
},
],
[selectedAttributes],
);
};
export default useAttributesTableColumns;

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: "Название",
accessorKey: "label",
size: 25,
},
{
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: 5,
},
],
[selectedModules],
);
};
export default useModulesTableColumns;

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,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

@@ -1,17 +1,16 @@
import { BaseProjectSchema, ProjectSchema, ProjectService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { ProjectSchema, ProjectService } from "../../../../../../../client";
import { notifications } from "../../../../../../../shared/lib/notifications.ts";
import { useProjectsEditorContext } from "../../../../../contexts/ProjectsEditorContext.tsx";
import { useMap } from "@mantine/hooks";
import { useState } from "react";
import useProjects from "../../../hooks/useProjects.tsx";
import { modals } from "@mantine/modals";
type Props = {
onUpdate: () => void;
refetchProjects: () => void;
}
const useProjectModal = ({ onUpdate }: Props) => {
const useProjectsTab = ({ refetchProjects }: Props) => {
const { onUpdate } = useProjectsEditorContext();
const editingProjects = useMap<number, ProjectSchema>();
const { projects, refetchProjects } = useProjects();
const [name, setName] = useState("");
const updateProject = (project: ProjectSchema) => {
ProjectService.updateProject({
@@ -62,34 +61,18 @@ const useProjectModal = ({ onUpdate }: Props) => {
.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 });
modals.openContextModal({
modal: "createProjectModal",
title: "Создание проекта",
withCloseButton: false,
innerProps: {
refetchProjects,
},
});
};
return {
projects,
name,
setName,
editingProjects,
handleEditClick,
handleDeleteClick,
@@ -97,4 +80,4 @@ const useProjectModal = ({ onUpdate }: Props) => {
};
};
export default useProjectModal;
export default useProjectsTab;

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,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,6 +1,6 @@
enum DragState {
DRAG_ENDED,
DRAG_DEAL,
DRAG_CARD,
DRAG_STATUS,
}

View File

@@ -23,7 +23,7 @@ const useDnd = ({
const {
summaries,
onDealDragEnd,
onCardDragEnd,
} = useCardsDnd({
summariesRaw,
refetchSummaries,
@@ -41,14 +41,14 @@ const useDnd = ({
if (result.draggableId.includes("status")) {
return onStatusDragEnd(result);
}
return onDealDragEnd(result);
return onCardDragEnd(result);
}
const onDragStart = (start: DragStart) => {
if (start.source.droppableId.includes("status")) {
setDragState(DragState.DRAG_STATUS);
} else {
setDragState(DragState.DRAG_DEAL);
setDragState(DragState.DRAG_CARD);
}
}

View File

@@ -1,85 +0,0 @@
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";
import { FullProjectSchema } from "../../../../client";
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<FullProjectSchema>
}
/>
</Stack>
);
};
export default ProjectsModal;

View File

@@ -1,8 +1,8 @@
import { Button, Fieldset, Flex, rem, Textarea, TextInput } from "@mantine/core";
import { Button, Fieldset, Flex, rem, Stack, Textarea, TextInput } from "@mantine/core";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import { useForm } from "@mantine/form";
import { CardGeneralFormType } from "../GeneralTab/GeneralTab.tsx";
import { ClientService, CardSchema, CardService } from "../../../../client";
import { CardSchema, CardService, ClientService } from "../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
@@ -53,38 +53,39 @@ const ClientTab = () => {
<Flex direction={"column"} flex={1} gap={rem(10)}>
<Flex flex={1}>
<Fieldset legend={"Клиент"} flex={1}>
<TextInput
disabled
placeholder={"Название"}
label={"Название"}
value={card?.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")}
/>
<Stack gap={rem(10)}>
<TextInput
disabled
placeholder={"Название"}
label={"Название"}
value={card?.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")}
/>
</Stack>
</Fieldset>
</Flex>
<Flex

View File

@@ -20,6 +20,7 @@ const UserForDepartmentSelect: FC<Props> = ({ cardId, ...selectProps }) => {
data={employees}
getLabelFn={(user: UserSchema) => `${user.firstName} ${user.secondName}`}
getValueFn={(user: UserSchema) => user.id.toString()}
searchable
{...selectProps}
/>
);

View File

@@ -53,7 +53,7 @@ const useEmployeesTab = () => {
if (!card) return;
modals.openContextModal({
modal: "assignUserModal",
modal: "assignEmployeeModal",
title: `Назначение исполнителя`,
withCloseButton: false,
innerProps: {

View File

@@ -38,6 +38,7 @@ import getAttributesFromCard from "../../../../components/CardAttributeFields/ut
import isModuleInProject, { Modules } from "../../utils/isModuleInProject.ts";
import PaymentLinkButton from "./components/PaymentLinkButton.tsx";
import PrintDealBarcodesButton from "./components/PrintDealBarcodesButton.tsx";
import ClientSelect from "../../../../components/Selects/ClientSelect/ClientSelect.tsx";
type Props = {
card: CardSchema;
@@ -57,6 +58,7 @@ const Content: FC<Props> = ({ card }) => {
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, card.board.project);
const isManagerIncluded = isModuleInProject(Modules.MANAGERS, card.board.project);
const isClientIncluded = isModuleInProject(Modules.CLIENTS, card.board.project);
const getInitialValues = (card: CardSchema): CardGeneralFormType => {
return {
@@ -96,6 +98,7 @@ const Content: FC<Props> = ({ card }) => {
...values,
statusId: values.status.id,
boardId: values.board.id,
clientId: values.client?.id ?? null,
shippingWarehouse: values.shippingWarehouse?.toString(),
attributes,
},
@@ -124,7 +127,7 @@ const Content: FC<Props> = ({ card }) => {
};
const handleSubmit = async (values: CardGeneralFormType) => {
// Updating client info if there changes
if (!isEqual(values.client, card.client)) {
if (!isEqual(values.client, card.client) && values.client?.id === card.clientId) {
await updateClientInfo(values);
}
@@ -199,7 +202,7 @@ const Content: FC<Props> = ({ card }) => {
label={"Статус"}
/>
<Textarea
h={rem(150)}
h={rem(120)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
@@ -208,6 +211,12 @@ const Content: FC<Props> = ({ card }) => {
placeholder={"Введите коментарий"}
{...form.getInputProps("comment")}
/>
{isClientIncluded && (
<ClientSelect
{...form.getInputProps("client")}
withLabel
/>
)}
{isServicesAndProductsIncluded && (
<ShippingWarehouseAutocomplete
placeholder={"Введите склад отгрузки"}

View File

@@ -16,20 +16,20 @@
flex: 2;
}
.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

@@ -234,18 +234,18 @@ const ProductAndServiceTab: FC = () => {
</ScrollArea>
</div>
<div className={styles["deal-container"]}>
<div className={styles["card-container"]}>
<ScrollArea offsetScrollbars>
<Flex
direction={"column"}
className={styles["deal-container-wrapper"]}>
className={styles["card-container-wrapper"]}>
<CardServicesTable
onKitAdd={onCardKitAdd}
{...cardServicesState}
/>
<Divider my={rem(15)} />
<div className={styles["deal-container-buttons"]}>
<div className={styles["card-container-buttons"]}>
<Button
disabled={isLocked}
variant={"default"}
@@ -262,7 +262,7 @@ const ProductAndServiceTab: FC = () => {
</Button>
</div>
<Divider my={rem(15)} />
<div className={styles["deal-container-buttons"]}>
<div className={styles["card-container-buttons"]}>
{isLocked ? (
<Button
onClick={onCancelBillClick}
@@ -282,7 +282,7 @@ const ProductAndServiceTab: FC = () => {
</Flex>
<Flex
direction={"column"}
className={styles["deal-container-wrapper"]}>
className={styles["card-container-wrapper"]}>
<Title order={3}>
Общая стоимость всех услуг:{" "}
{getTotalPrice().toLocaleString("ru")}

View File

@@ -16,6 +16,8 @@ import CardsPageHeader from "../components/CardsPageHeader/CardsPageHeader.tsx";
import useProjects from "../hooks/useProjects.tsx";
import Boards from "../../../components/Dnd/Boards/Boards/Boards.tsx";
import useBoards from "../hooks/useBoards.tsx";
import { ProjectsEditorContextProvider } from "../contexts/ProjectsEditorContext.tsx";
import ProjectsEditorDrawer from "../drawers/ProjectsEditorDrawer/ProjectsEditorDrawer.tsx";
export const CardsPage: FC = () => {
const { projects, refetchProjects } = useProjects();
@@ -81,13 +83,15 @@ export const CardsPage: FC = () => {
>
<PrefillCardContextProvider>
<PrefillCardsWithExcelContextProvider>
<CardsPageHeader
form={form}
displayMode={displayMode}
setDisplayMode={setDisplayMode}
projects={projects}
refetchProjects={refetchProjects}
/>
<ProjectsEditorContextProvider onUpdate={refetchProjects}>
<CardsPageHeader
form={form}
displayMode={displayMode}
setDisplayMode={setDisplayMode}
projects={projects}
/>
<ProjectsEditorDrawer />
</ProjectsEditorContextProvider>
<PageBlock
style={{
display: "flex",

View File

@@ -15,10 +15,22 @@ export type BaseFormInputProps<T> = {
value: T;
error?: string | null;
};
export const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("ru-RU");
};
export const formatDateTime = (dateTimeStr: string) => {
const date = new Date(dateTimeStr);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
export const getProductFields = (product: ProductSchema) => {
return (
Object.entries(product)