feat: added tags for cards, aligned status headers

This commit is contained in:
2025-03-09 19:32:45 +04:00
parent 56135ae10c
commit ea80e92c18
48 changed files with 876 additions and 85 deletions

View File

@@ -25,7 +25,7 @@
"@mantine/modals": "^7.11.2",
"@mantine/notifications": "^7.11.2",
"@reduxjs/toolkit": "^2.2.6",
"@tabler/icons-react": "^3.11.0",
"@tabler/icons-react": "3.11.0",
"@tanstack/react-query": "^5.51.9",
"@tanstack/react-router": "^1.45.6",
"@tanstack/router-devtools": "^1.45.6",
@@ -75,10 +75,10 @@
"postcss": "^8.4.39",
"postcss-preset-mantine": "^1.16.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.77.8",
"sass": "^1.81.1",
"typescript": "^5.5.3",
"typescript-eslint": "^7.16.1",
"vite": "^5.3.4",
"vite": "^6.0.1",
"yarn-upgrade-all": "^0.7.2"
},
"packageManager": "yarn@4.1.0"

View File

@@ -27,6 +27,7 @@ export type { BarcodeTemplateUpdateRequest } from './models/BarcodeTemplateUpdat
export type { BarcodeTemplateUpdateResponse } from './models/BarcodeTemplateUpdateResponse';
export type { BaseAttributeSchema } from './models/BaseAttributeSchema';
export type { BaseBoardSchema } from './models/BaseBoardSchema';
export type { BaseCardTagSchema } from './models/BaseCardTagSchema';
export type { BaseEnumListSchema } from './models/BaseEnumListSchema';
export type { BaseEnumSchema } from './models/BaseEnumSchema';
export type { BaseMarketplaceSchema } from './models/BaseMarketplaceSchema';
@@ -101,6 +102,7 @@ export type { CardStatusHistorySchema } from './models/CardStatusHistorySchema';
export type { CardSummary } from './models/CardSummary';
export type { CardSummaryReorderRequest } from './models/CardSummaryReorderRequest';
export type { CardSummaryResponse } from './models/CardSummaryResponse';
export type { CardTagSchema } from './models/CardTagSchema';
export type { CardUpdateGeneralInfoRequest } from './models/CardUpdateGeneralInfoRequest';
export type { CardUpdateGeneralInfoResponse } from './models/CardUpdateGeneralInfoResponse';
export type { CardUpdateProductQuantityRequest } from './models/CardUpdateProductQuantityRequest';
@@ -169,6 +171,8 @@ export type { CreateShippingWarehouseRequest } from './models/CreateShippingWare
export type { CreateShippingWarehouseResponse } from './models/CreateShippingWarehouseResponse';
export type { CreateStatusRequest } from './models/CreateStatusRequest';
export type { CreateStatusResponse } from './models/CreateStatusResponse';
export type { CreateTagRequest } from './models/CreateTagRequest';
export type { CreateTagResponse } from './models/CreateTagResponse';
export type { CreateTaskResponse } from './models/CreateTaskResponse';
export type { CreateTransactionTagRequest } from './models/CreateTransactionTagRequest';
export type { CreateUserRequest } from './models/CreateUserRequest';
@@ -196,6 +200,7 @@ export type { DeleteShippingProductResponse } from './models/DeleteShippingProdu
export type { DeleteShippingWarehouseRequest } from './models/DeleteShippingWarehouseRequest';
export type { DeleteShippingWarehouseResponse } from './models/DeleteShippingWarehouseResponse';
export type { DeleteStatusResponse } from './models/DeleteStatusResponse';
export type { DeleteTagResponse } from './models/DeleteTagResponse';
export type { DeleteTransactionResponse } from './models/DeleteTransactionResponse';
export type { DeleteTransactionTagResponse } from './models/DeleteTransactionTagResponse';
export type { DeleteUserRequest } from './models/DeleteUserRequest';
@@ -346,6 +351,8 @@ export type { StartPauseByShiftIdResponse } from './models/StartPauseByShiftIdRe
export type { StartPauseByUserIdResponse } from './models/StartPauseByUserIdResponse';
export type { StartShiftResponse } from './models/StartShiftResponse';
export type { StatusSchema } from './models/StatusSchema';
export type { SwitchTagRequest } from './models/SwitchTagRequest';
export type { SwitchTagResponse } from './models/SwitchTagResponse';
export type { SynchronizeMarketplaceRequest } from './models/SynchronizeMarketplaceRequest';
export type { TaskInfoResponse } from './models/TaskInfoResponse';
export type { TimeTrackingData } from './models/TimeTrackingData';
@@ -393,6 +400,8 @@ export type { UpdateStatusOrderRequest } from './models/UpdateStatusOrderRequest
export type { UpdateStatusOrderResponse } from './models/UpdateStatusOrderResponse';
export type { UpdateStatusRequest } from './models/UpdateStatusRequest';
export type { UpdateStatusResponse } from './models/UpdateStatusResponse';
export type { UpdateTagRequest } from './models/UpdateTagRequest';
export type { UpdateTagResponse } from './models/UpdateTagResponse';
export type { UpdateTimeTrackingRecordRequest } from './models/UpdateTimeTrackingRecordRequest';
export type { UpdateTimeTrackingRecordResponse } from './models/UpdateTimeTrackingRecordResponse';
export type { UpdateTransactionRequest } from './models/UpdateTransactionRequest';
@@ -421,6 +430,7 @@ export { BillingService } from './services/BillingService';
export { BoardService } from './services/BoardService';
export { CardService } from './services/CardService';
export { CardGroupService } from './services/CardGroupService';
export { CardTagService } from './services/CardTagService';
export { ClientService } from './services/ClientService';
export { DepartmentService } from './services/DepartmentService';
export { MarketplaceService } from './services/MarketplaceService';

View File

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

View File

@@ -12,7 +12,7 @@ export type CardGeneralInfoSchema = {
manager?: (UserSchema | null);
boardId: number;
statusId: number;
isServicesProfitAccounted: boolean;
clientId: (number | null);
tags: Array<string>;
};

View File

@@ -11,6 +11,7 @@ import type { CardGroupSchema } from './CardGroupSchema';
import type { CardProductSchema } from './CardProductSchema';
import type { CardServiceSchema } from './CardServiceSchema';
import type { CardStatusHistorySchema } from './CardStatusHistorySchema';
import type { CardTagSchema } from './CardTagSchema';
import type { ClientSchema } from './ClientSchema';
import type { PalletSchema } from './PalletSchema';
import type { ShippingWarehouseSchema } from './ShippingWarehouseSchema';
@@ -39,6 +40,7 @@ export type CardSchema = {
pallets?: Array<PalletSchema>;
boxes?: Array<BoxSchema>;
employees?: Array<CardEmployeesSchema>;
tags?: Array<CardTagSchema>;
attributes: Array<CardAttributeSchema>;
};

View File

@@ -6,6 +6,7 @@ import type { BaseMarketplaceSchema } from './BaseMarketplaceSchema';
import type { BoardSchema } from './BoardSchema';
import type { CardBillRequestSchema } from './CardBillRequestSchema';
import type { CardGroupSchema } from './CardGroupSchema';
import type { CardTagSchema } from './CardTagSchema';
import type { StatusSchema } from './StatusSchema';
export type CardSummary = {
id: number;
@@ -18,6 +19,7 @@ export type CardSummary = {
rank: number;
baseMarketplace?: (BaseMarketplaceSchema | null);
totalProducts: number;
tags: Array<CardTagSchema>;
shipmentWarehouseId: (number | null);
shipmentWarehouseName: (string | null);
billRequest?: (CardBillRequestSchema | null);

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CardTagSchema = {
name: string;
projectId: number;
id: number;
};

View File

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

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateTagResponse = {
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 DeleteTagResponse = {
ok: boolean;
message: string;
};

View File

@@ -3,12 +3,14 @@
/* tslint:disable */
/* eslint-disable */
import type { AttributeSchema } from './AttributeSchema';
import type { CardTagSchema } from './CardTagSchema';
import type { ModuleSchema } from './ModuleSchema';
export type FullProjectSchema = {
name: string;
id: number;
attributes: Array<AttributeSchema>;
modules: Array<ModuleSchema>;
tags: Array<CardTagSchema>;
boardsCount: number;
};

View File

@@ -9,6 +9,7 @@ export type GetProfitChartDataRequest = {
projectId: number;
boardId: number;
cardStatusId: number;
cardTagId: number;
managerId: number;
expenseTagId: number;
incomeTagId: number;

View File

@@ -10,6 +10,7 @@ export type GetProfitTableDataRequest = {
projectId: number;
boardId: number;
cardStatusId: number;
cardTagId: number;
managerId: number;
expenseTagId: number;
incomeTagId: number;

View File

@@ -4,5 +4,6 @@
/* eslint-disable */
export type ProductsAndServicesGeneralInfoSchema = {
shippingWarehouse?: (string | null);
isServicesProfitAccounted: boolean;
};

View File

@@ -2,4 +2,4 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ProfitTableGroupBy = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type ProfitTableGroupBy = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;

View File

@@ -3,11 +3,13 @@
/* tslint:disable */
/* eslint-disable */
import type { AttributeSchema } from './AttributeSchema';
import type { CardTagSchema } from './CardTagSchema';
import type { ModuleSchema } from './ModuleSchema';
export type ProjectSchema = {
name: string;
id: number;
attributes: Array<AttributeSchema>;
modules: Array<ModuleSchema>;
tags: Array<CardTagSchema>;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SwitchTagRequest = {
tagId: number;
cardId?: (number | null);
groupId?: (number | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SwitchTagResponse = {
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 { CardTagSchema } from './CardTagSchema';
export type UpdateTagRequest = {
tag: CardTagSchema;
};

View File

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

View File

@@ -0,0 +1,97 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CreateTagRequest } from '../models/CreateTagRequest';
import type { CreateTagResponse } from '../models/CreateTagResponse';
import type { DeleteTagResponse } from '../models/DeleteTagResponse';
import type { SwitchTagRequest } from '../models/SwitchTagRequest';
import type { SwitchTagResponse } from '../models/SwitchTagResponse';
import type { UpdateTagRequest } from '../models/UpdateTagRequest';
import type { UpdateTagResponse } from '../models/UpdateTagResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class CardTagService {
/**
* Create Tag
* @returns CreateTagResponse Successful Response
* @throws ApiError
*/
public static createTag({
requestBody,
}: {
requestBody: CreateTagRequest,
}): CancelablePromise<CreateTagResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/card-tag/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Tag
* @returns UpdateTagResponse Successful Response
* @throws ApiError
*/
public static updateTag({
requestBody,
}: {
requestBody: UpdateTagRequest,
}): CancelablePromise<UpdateTagResponse> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/card-tag/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Tag
* @returns DeleteTagResponse Successful Response
* @throws ApiError
*/
public static deleteTag({
cardTagId,
}: {
cardTagId: number,
}): CancelablePromise<DeleteTagResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/card-tag/{card_tag_id}',
path: {
'card_tag_id': cardTagId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Switch Tag
* @returns SwitchTagResponse Successful Response
* @throws ApiError
*/
public static switchTag({
requestBody,
}: {
requestBody: SwitchTagRequest,
}): CancelablePromise<SwitchTagResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/card-tag/switch',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -7,6 +7,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import CardTags from "../CardTags/CardTags.tsx";
type Props = {
cards: CardSummary[];
@@ -75,6 +76,7 @@ export const CardGroupView: FC<Props> = ({ cards, group }) => {
/>
))}
</Flex>
<CardTags groupId={group.id} tags={cards[0].tags}/>
{isServicesAndProductsIncluded && (
<Flex
p={rem(10)}

View File

@@ -11,6 +11,7 @@ import { useContextMenu } from "mantine-contextmenu";
import useCardSummaryState from "./useCardSummaryState.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import CardTags from "../CardTags/CardTags.tsx";
type Props = {
cardSummary: CardSummary;
@@ -26,7 +27,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, selectedProject);
const isClientIncluded = isModuleInProject(Modules.CLIENTS, selectedProject);
const onDealSummaryClick = () => {
const onCardSummaryClick = () => {
CardService.getCardById({ cardId: cardSummary.id }).then(card => {
setSelectedCard(card);
});
@@ -59,7 +60,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
icon: <IconTrash />,
},
])}
onClick={() => onDealSummaryClick()}
onClick={() => onCardSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
>
@@ -95,6 +96,9 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
</Text>
</Flex>
)}
{!cardSummary.group?.id && (
<CardTags cardId={cardSummary.id} tags={cardSummary.tags}/>
)}
<Flex align={"center"} justify={"space-between"}>
<Flex align={"center"} gap={rem(5)}>
<CopyButton value={cardSummary.id.toString()}>

View File

@@ -0,0 +1,21 @@
.add-tag-button {
background-color: var(--mantine-color-dark-6);
border: 1px gray dashed;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
padding: 0;
cursor: pointer;
}
.add-tag-button:hover {
border-color: white;
}
.add-tag-button-icon {
color: gray;
}
.add-tag-button-icon:hover {
color: white;
}

View File

@@ -0,0 +1,98 @@
import { Center, Checkbox, Group, Menu, Pill, rem, Stack, Text } from "@mantine/core";
import { CardTagSchema, CardTagService } from "../../../../client";
import { IconPlus } from "@tabler/icons-react";
import styles from "./CardTags.module.css";
import classNames from "classnames";
import React from "react";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useCardPageContext } from "../../../../pages/CardsPage/contexts/CardPageContext.tsx";
type Props = {
cardId?: number;
groupId?: number;
tags: CardTagSchema[];
}
const CardTags = ({ tags, cardId, groupId }: Props) => {
const { selectedProject } = useProjectsContext();
const { refetchCards } = useCardPageContext();
if (selectedProject?.tags.length === 0) return;
const onTagClick = (tag: CardTagSchema, event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
event.stopPropagation();
CardTagService.switchTag({
requestBody: {
cardId: cardId ?? null,
groupId: groupId ?? null,
tagId: tag.id,
},
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetchCards();
})
.catch(err => console.log(err));
};
const addTagButton = (
<Menu withArrow>
<Menu.Target>
<button
onClick={e => e.stopPropagation()}
className={classNames(styles["add-tag-button"])}
>
<Center>
<IconPlus
size={"1.2em"}
className={classNames(styles["add-tag-button-icon"])}
/>
</Center>
</button>
</Menu.Target>
<Menu.Dropdown>
<Stack
p={"xs"}
gap={"sm"}
onClick={e => e.stopPropagation()}
>
{selectedProject?.tags.map(tag => (
<Group key={tag.id} wrap={"nowrap"}>
<Checkbox
checked={!!tags.find(cardTag => cardTag.id === tag.id)}
onClick={(event) => onTagClick(tag, event)}
/>
<Text>{tag.name}</Text>
</Group>
))}
</Stack>
</Menu.Dropdown>
</Menu>
);
const pills = tags.map(tag => {
return (
<Pill
key={tag.id}
style={{
color: "gray",
backgroundColor: "var(--mantine-color-dark-6)",
border: "1px solid gray",
}}
>
{tag.name}
</Pill>
);
});
return (
<Group gap={rem(4)}>
{addTagButton}
{pills}
</Group>
);
};
export default CardTags;

View File

@@ -1,5 +1,5 @@
import styles from "../../../../pages/CardsPage/ui/CardsPage.module.css";
import { Divider, Text, Title } from "@mantine/core";
import { Divider, Stack, Text, Title } from "@mantine/core";
import getColumnColor from "../../Cards/CardsDndColumn/utils/getColumnColor.ts";
import { BoardSchema, CardSummary, StatusSchema } from "../../../../client";
import { getPluralForm } from "../../../../shared/lib/utils.ts";
@@ -10,6 +10,7 @@ import { useContextMenu } from "mantine-contextmenu";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import useStatus from "./hooks/useStatus.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import { useEqualHeightsContext } from "./contexts/EqualHeightContext.tsx";
type Props = {
@@ -25,6 +26,10 @@ 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 {
divRefs,
} = useEqualHeightsContext();
const {
onEditStatusClick,
onDeleteStatusClick,
@@ -60,7 +65,30 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
},
]);
const header = (
<Stack h={"100%"} gap={0} justify={"space-between"}>
<Stack gap={0}>
<Title
style={{ textAlign: "center" }}
size={"h4"}>
{status.name}
</Title>
<Text style={{ textAlign: "center", textWrap: "nowrap" }}>
{getDealsText()}
</Text>
</Stack>
<Divider
size={"xl"}
color={getColumnColor(index)}
/>
</Stack>
);
return (
<div
key={index}
ref={el => (divRefs.current[index] = el)}
>
<Droppable
isDropDisabled={isDropDisabled}
droppableId={"status-" + status.id.toString()}
@@ -70,6 +98,7 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{ height: "100%" }}
>
<Draggable
draggableId={"status-" + status.id.toString()}
@@ -84,19 +113,7 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
className={styles["header-statuses"]}
onContextMenu={contextMenu()}
>
<Title
style={{ textAlign: "center" }}
size={"h4"}>
{status.name}
</Title>
<Text style={{ textAlign: "center", textWrap: "nowrap" }}>
{getDealsText()}
</Text>
<Divider
size={"xl"}
my={10}
color={getColumnColor(index)}
/>
{header}
</div>
)}
</Draggable>
@@ -104,6 +121,7 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
</div>
)}
</Droppable>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import React, { createContext, FC, useContext, useEffect, useRef, useState } from "react";
import { StatusSchema } from "../../../../../client";
type EqualHeightsContextState = {
divRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
};
const EqualHeightsContext = createContext<EqualHeightsContextState | undefined>(
undefined,
);
type EqualHeightsContextProps = {
statuses: StatusSchema[];
}
const useEqualHeightsContextState = ({ statuses }: EqualHeightsContextProps) => {
const divRefs = useRef<(HTMLDivElement | null)[]>([]);
const updateHeights = () => {
divRefs.current.forEach(div => {
if (div) {
div.style.minHeight = "auto";
div.style.height = "auto";
}
});
const heights = divRefs.current.map(div => div?.offsetHeight || 0);
const newMaxHeight = Math.max(...heights);
divRefs.current.forEach(div => {
if (div) {
div.style.minHeight = newMaxHeight + "px";
div.style.height = newMaxHeight + "px";
}
});
};
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
useEffect(() => {
updateHeights();
}, [windowWidth, divRefs.current.length, statuses]);
return {
divRefs,
};
};
type EqualHeightsContextProviderProps = {
children: React.ReactNode;
} & EqualHeightsContextProps;
export const EqualHeightsContextProvider: FC<EqualHeightsContextProviderProps> = ({ children, statuses }) => {
const state = useEqualHeightsContextState({ statuses });
return (
<EqualHeightsContext.Provider value={state}>
{children}
</EqualHeightsContext.Provider>
);
};
export const useEqualHeightsContext = () => {
const context = useContext(EqualHeightsContext);
if (!context) {
throw new Error(
"useEqualHeightsContext must be used within a EqualHeightsContextProvider",
);
}
return context;
};

View File

@@ -6,6 +6,7 @@ import { Flex, rem, Stack } from "@mantine/core";
import { DragDropContext } from "@hello-pangea/dnd";
import useDnd from "../../../../pages/CardsPage/hooks/useDnd.tsx";
import Status from "../Status/Status.tsx";
import { EqualHeightsContextProvider } from "../Status/contexts/EqualHeightContext.tsx";
type Props = {
@@ -67,6 +68,7 @@ const Statuses = ({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<EqualHeightsContextProvider statuses={statuses}>
<Flex
justify={"space-between"}
direction={"column"}
@@ -80,6 +82,7 @@ const Statuses = ({
</Flex>
<CardsDndFooter dragState={dragState} />
</Flex>
</EqualHeightsContextProvider>
</DragDropContext>
);
};

View File

@@ -0,0 +1,26 @@
import { FC } from "react";
import { CardTagSchema, ProjectSchema } from "../../../client";
import ObjectSelect, { ObjectSelectProps } from "../../ObjectSelect/ObjectSelect.tsx";
type OtherProps = {
project: ProjectSchema | null;
}
type SelectProps = Omit<ObjectSelectProps<CardTagSchema | null>, "data" | "getLabelFn" | "getValueFn">;
type Props = OtherProps & SelectProps;
const CardTagSelect: FC<Props> = ({ project, ...props }) => {
const onClear = () => props.onChange(null);
return (
<ObjectSelect
data={project?.tags ?? []}
searchable
placeholder={"Выберите тег"}
onClear={onClear}
{...props}
/>
);
};
export default CardTagSelect;

View File

@@ -38,6 +38,7 @@ import StatusModal from "../pages/CardsPage/modals/StatusModal/StatusModal.tsx";
import AttributeModal from "../pages/AdminPage/tabs/Attributes/modals/AttributeModal.tsx";
import CreateProjectModal
from "../pages/CardsPage/drawers/ProjectEditDrawer/tabs/General/modals/CreateProjectModal.tsx";
import CardTagModal from "../pages/CardsPage/drawers/ProjectEditDrawer/tabs/Tags/modals/CardTagModal.tsx";
export const modals = {
enterDeadline: EnterDeadlineModal,
@@ -77,4 +78,5 @@ export const modals = {
statusModal: StatusModal,
attributeModal: AttributeModal,
createProjectModal: CreateProjectModal,
cardTagModal: CardTagModal,
};

View File

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

View File

@@ -42,7 +42,7 @@ const CardEditDrawer: FC = () => {
const getTabPanel = (value: string, component: ReactNode) => {
return (
<Tabs.Panel value={value}>
<Tabs.Panel key={value} value={value}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -61,6 +61,7 @@ const CardEditDrawer: FC = () => {
const getTabs = () => {
const moduleTabs = modules.map(module => (
<Tabs.Tab
key={module.info.key}
value={module.info.key}
leftSection={module.info.icon}
>

View File

@@ -22,9 +22,10 @@ import { ButtonCopyControlled } from "../../../../../../components/ButtonCopyCon
import { useClipboard } from "@mantine/hooks";
import ProjectSelect from "../../../../../../components/ProjectSelect/ProjectSelect.tsx";
import BoardSelect from "../../../../../../components/BoardSelect/BoardSelect.tsx";
import CardStatusSelect from "../../../../../../components/DealStatusSelect/CardStatusSelect.tsx";
import CardStatusSelect from "../../../../../../components/CardStatusSelect/CardStatusSelect.tsx";
import CardAttributeFields from "../../../../../../components/CardAttributeFields/CardAttributeFields.tsx";
import getAttributesFromCard from "../../../../../../components/CardAttributeFields/utils/getAttributesFromCard.ts";
import CardTagsInput from "../../../../components/CardTagsInput/CardTagsInput.tsx";
type Props = {
card: CardSchema;
@@ -41,6 +42,7 @@ const Content: FC<Props> = ({ card }) => {
const clipboard = useClipboard();
const queryClient = useQueryClient();
const [project, setProject] = useState<ProjectSchema | null>(card.board.project);
const [cardTags, setCardTags] = useState<string[]>(card.tags?.map(tag => tag.name) ?? []);
const getInitialValues = (card: CardSchema): CardGeneralFormType => {
return {
@@ -82,6 +84,7 @@ const Content: FC<Props> = ({ card }) => {
boardId: values.board.id,
clientId: values.client?.id ?? null,
attributes,
tags: cardTags,
},
},
}).then(({ ok, message }) => {
@@ -125,6 +128,20 @@ const Content: FC<Props> = ({ card }) => {
});
};
const cancelChanges = () => {
form.reset();
setCardTags(card.tags?.map(tag => tag.name) ?? []);
};
const isEqualValues = () => {
const initialCardTagsSet = new Set(card.tags?.map(tag => tag.name) ?? []);
const tagsEqual = initialCardTagsSet.size === cardTags.length &&
cardTags.every(element => initialCardTagsSet.has(element));
return isEqual(initialValues, form.values) && tagsEqual;
};
return (
<form onSubmit={form.onSubmit(values => handleSubmit(values))}>
<Flex
@@ -177,6 +194,12 @@ const Content: FC<Props> = ({ card }) => {
placeholder={"Введите коментарий"}
{...form.getInputProps("comment")}
/>
{project && project?.tags.length > 0 && (
<CardTagsInput
value={cardTags}
onChange={setCardTags}
/>
)}
{project && (
<CardAttributeFields
project={project}
@@ -223,14 +246,14 @@ const Content: FC<Props> = ({ card }) => {
<Button
color={"red"}
type={"reset"}
disabled={isEqual(initialValues, form.values)}
onClick={() => form.reset()}>
disabled={isEqualValues()}
onClick={cancelChanges}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqual(initialValues, form.values)}>
disabled={isEqualValues()}>
Сохранить изменения
</Button>
</Group>

View File

@@ -1,11 +1,12 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { IconHexagons, IconSettings, IconSubtask } from "@tabler/icons-react";
import { IconHexagons, IconSettings, IconSubtask, IconTags } from "@tabler/icons-react";
import { ReactNode } from "react";
import { motion } from "framer-motion";
import { useProjectsEditorContext } from "../../contexts/ProjectsEditorContext.tsx";
import General from "./tabs/General/General.tsx";
import Attributes from "./tabs/Attributes/Attributes.tsx";
import Modules from "./tabs/Modules/Modules.tsx";
import Tags from "./tabs/Tags/Tags.tsx";
const ProjectEditDrawer = () => {
@@ -67,11 +68,17 @@ const ProjectEditDrawer = () => {
leftSection={<IconSubtask />}>
Атрибуты
</Tabs.Tab>
<Tabs.Tab
value={"tags"}
leftSection={<IconTags />}>
Теги
</Tabs.Tab>
</Tabs.List>
{getTabPanel("general", <General/>)}
{getTabPanel("attributes", <Attributes/>)}
{getTabPanel("general", <General />)}
{getTabPanel("attributes", <Attributes />)}
{getTabPanel("modules", <Modules />)}
{getTabPanel("tags", <Tags />)}
</Tabs>
</Drawer>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Flex, Modal, NumberInput, rem } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { CardsPageState } from "../hooks/useCardsPageState.tsx";
import ObjectSelect from "../../../components/ObjectSelect/ObjectSelect.tsx";
import CardStatusSelect from "../../../components/DealStatusSelect/CardStatusSelect.tsx";
import CardStatusSelect from "../../../components/CardStatusSelect/CardStatusSelect.tsx";
import BaseMarketplaceSelect from "../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import ClientSelectNew from "../../../components/Selects/ClientSelectNew/ClientSelectNew.tsx";
import { useDisclosure } from "@mantine/hooks";

View File

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

View File

@@ -6,13 +6,15 @@ import useAllTransactionTagsList from "../../../AdminPage/hooks/useAllTransactio
type IsIncome = {
isIncome: boolean;
}
type Props = Omit<
ObjectSelectProps<TransactionTagSchema | null>,
"data" | "getValueFn" | "getLabelFn"
> & IsIncome;
const TransactionTagSelect: FC<Props> = props => {
const TransactionTagSelect: FC<Props> = ({ isIncome, ...props }) => {
let { objects: tags } = useAllTransactionTagsList();
tags = tags.filter(tag => tag.isIncome === props.isIncome);
tags = tags.filter(tag => tag.isIncome === isIncome);
return (
<ObjectSelect

View File

@@ -3,7 +3,7 @@ import { Checkbox, CheckboxProps, Divider, Stack, Text } from "@mantine/core";
import ClientSelectNew from "../../../../../../components/Selects/ClientSelectNew/ClientSelectNew.tsx";
import {
BaseMarketplaceSchema,
BoardSchema,
BoardSchema, CardTagSchema,
ClientSchema,
ProjectSchema,
StatusSchema,
@@ -13,12 +13,14 @@ import {
import { ObjectSelectProps } from "../../../../../../components/ObjectSelect/ObjectSelect.tsx";
import BaseMarketplaceSelect
from "../../../../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import CardStatusSelect from "../../../../../../components/DealStatusSelect/CardStatusSelect.tsx";
import CardStatusSelect from "../../../../../../components/CardStatusSelect/CardStatusSelect.tsx";
import { ProfitTableSegmentedControl } from "../ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
import ManagerSelect from "../../../../../../components/ManagerSelect/ManagerSelect.tsx";
import TransactionTagSelect from "../../../../components/ExpenseTagSelect/TransactionTagSelect.tsx";
import BoardSelect from "../../../../../../components/BoardSelect/BoardSelect.tsx";
import ProjectSelect from "../../../../../../components/ProjectSelect/ProjectSelect.tsx";
import CardTagSelect from "../../../../../../components/Selects/CardTagSelect/CardTagSelect.tsx";
import { useEffect } from "react";
type SelectProps<T> = Omit<
@@ -41,6 +43,8 @@ type FiltersProps = {
statusSelectProps?: Omit<ObjectSelectProps<StatusSchema | null>, "data">;
cardTagSelectProps?: Omit<ObjectSelectProps<CardTagSchema | null>, "data">;
managerSelectProps?: SelectProps<UserSchema | null | undefined>;
onManagerClear?: () => void;
@@ -78,6 +82,21 @@ export const Filters = (props: FiltersProps) => {
);
};
useEffect(() => {
if (props.boardSelectProps?.onClear) {
props.boardSelectProps.onClear();
}
if (props.cardTagSelectProps && !props.projectSelectProps?.value) {
props.cardTagSelectProps.onChange(null);
}
}, [props.projectSelectProps?.value]);
useEffect(() => {
if (props.statusSelectProps?.onClear) {
props.statusSelectProps.onClear();
}
}, [props.boardSelectProps?.value]);
return (
<Stack mb={"lg"}>
<Divider />
@@ -104,6 +123,7 @@ export const Filters = (props: FiltersProps) => {
project={props.projectSelectProps?.value ?? null}
{...props.boardSelectProps}
clearable
disabled={!props.projectSelectProps?.value}
/>
}
{props.statusSelectProps &&
@@ -111,6 +131,16 @@ export const Filters = (props: FiltersProps) => {
board={props.boardSelectProps?.value ?? null}
{...props.statusSelectProps}
clearable
disabled={!props.boardSelectProps?.value}
/>
}
{props.cardTagSelectProps &&
<CardTagSelect
project={props.projectSelectProps?.value ?? null}
{...props.cardTagSelectProps}
clearable
searchable
disabled={!props.projectSelectProps?.value}
/>
}
{props.clientSelectProps &&

View File

@@ -19,6 +19,7 @@ export const useProfitTableColumns = ({ groupTableBy, statuses }: Props) => {
[GroupStatisticsTable.BY_MARKETPLACES]: "Маркетплейс",
[GroupStatisticsTable.BY_WAREHOUSES]: "Склад отгрузки",
[GroupStatisticsTable.BY_MANAGERS]: "Менеджер",
[GroupStatisticsTable.BY_TAGS]: "Тег",
};
const getConditionalColumns = (): MRT_ColumnDef<ProfitTableDataItem>[] => {

View File

@@ -11,6 +11,7 @@ export enum GroupStatisticsTable {
BY_WAREHOUSES,
BY_MARKETPLACES,
BY_MANAGERS,
BY_TAGS,
}
type ControlProps = Omit<SegmentedControlProps, "data">;
@@ -22,7 +23,7 @@ type OtherProps = {
type Props = ControlProps & OtherProps;
export const ProfitTableSegmentedControl: FC<Props> = props => {
export const ProfitTableSegmentedControl: FC<Props> = ({ selectedBoard, selectedProject, ...props}) => {
const data: (string | SegmentedControlItem)[] = [
{
label: "По датам",
@@ -39,12 +40,17 @@ export const ProfitTableSegmentedControl: FC<Props> = props => {
{
label: "По доскам",
value: GroupStatisticsTable.BY_BOARDS.toString(),
disabled: !props.selectedProject,
disabled: !selectedProject,
},
{
label: "По статусам",
value: GroupStatisticsTable.BY_STATUSES.toString(),
disabled: !props.selectedBoard,
disabled: !selectedBoard,
},
{
label: "По тегам",
value: GroupStatisticsTable.BY_TAGS.toString(),
disabled: !selectedProject,
},
{
label: "По складам отгрузки",
@@ -67,15 +73,15 @@ export const ProfitTableSegmentedControl: FC<Props> = props => {
useEffect(() => {
if (props.value === GroupStatisticsTable.BY_STATUSES.toString()) {
if (!props.selectedProject) {
if (!selectedProject) {
setGrouping(GroupStatisticsTable.BY_PROJECTS);
} else if (!props.selectedBoard) {
} else if (!selectedBoard) {
setGrouping(GroupStatisticsTable.BY_BOARDS);
}
} else if (props.value === GroupStatisticsTable.BY_BOARDS.toString() && !props.selectedProject) {
} else if (props.value === GroupStatisticsTable.BY_BOARDS.toString() && !selectedProject) {
setGrouping(GroupStatisticsTable.BY_PROJECTS);
}
}, [props.selectedBoard, props.selectedProject]);
}, [selectedBoard, selectedProject]);
return (
<SegmentedControl

View File

@@ -29,10 +29,11 @@ const useProfitTabContextState = () => {
project: null,
board: null,
status: null,
cardTag: null,
manager: null,
expenseTag: null,
incomeTag: null,
isCompletedOnly: true,
isCompletedOnly: false,
},
});
const [isChartLoading, setIsChartLoading] = useState(false);
@@ -53,6 +54,7 @@ const useProfitTabContextState = () => {
projectId: form.values.project?.id ?? -1,
boardId: form.values.board?.id ?? -1,
cardStatusId: form.values.status?.id ?? -1,
cardTagId: form.values.cardTag?.id ?? -1,
managerId: form.values.manager?.id ?? -1,
expenseTagId: form.values.expenseTag?.id ?? -1,
incomeTagId: form.values.incomeTag?.id ?? -1,

View File

@@ -43,6 +43,7 @@ export const ProfitFiltersModal = ({ form }: Props) => {
projectSelectProps={form.getInputProps("project")}
boardSelectProps={form.getInputProps("board")}
statusSelectProps={form.getInputProps("status")}
cardTagSelectProps={form.getInputProps("cardTag")}
managerSelectProps={form.getInputProps("manager")}
onManagerClear={() => form.setFieldValue("manager", null)}
isCompletedOnlyCheckboxProps={form.getInputProps("isCompletedOnly", { type: "checkbox" })}

View File

@@ -2,7 +2,7 @@ import {
GroupStatisticsTable,
} from "../tabs/ProfitTab/components/ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
import {
BaseMarketplaceSchema, BoardSchema,
BaseMarketplaceSchema, BoardSchema, CardTagSchema,
ClientSchema,
ProjectSchema,
StatusSchema,
@@ -18,6 +18,7 @@ export interface FormFilters {
project: ProjectSchema | null;
board: BoardSchema | null;
status: StatusSchema | null;
cardTag: CardTagSchema | null;
manager: UserSchema | null;
isCompletedOnly: boolean;
expenseTag: TransactionTagSchema | null;