feat: deals viewer mode and links for viewers

This commit is contained in:
2025-07-04 15:12:37 +04:00
parent 82e2ef6db2
commit c3d135eba9
27 changed files with 935 additions and 734 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,6 +11,7 @@ import type { ClientGetResponse } from '../models/ClientGetResponse';
import type { ClientUpdateDetailsRequest } from '../models/ClientUpdateDetailsRequest';
import type { ClientUpdateRequest } from '../models/ClientUpdateRequest';
import type { ClientUpdateResponse } from '../models/ClientUpdateResponse';
import type { CreateGuestUrlResponse } from '../models/CreateGuestUrlResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
@@ -148,4 +149,25 @@ export class ClientService {
},
});
}
/**
* Client Link
* @returns CreateGuestUrlResponse Successful Response
* @throws ApiError
*/
public static clientLink({
clientId,
}: {
clientId: number,
}): CancelablePromise<CreateGuestUrlResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/client/link/{client_id}',
path: {
'client_id': clientId,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -8,9 +8,10 @@ import { CardGeneralFormType } from "../../pages/CardsPage/drawers/CardEditDrawe
type Props = {
project: ProjectSchema;
form: UseFormReturnType<CardGeneralFormType>;
readOnly: boolean;
}
const CardAttributeFields = ({ project, form }: Props) => {
const CardAttributeFields = ({ project, form, readOnly }: Props) => {
const attributes: AttributeSchema[] = [];
project.attributes.forEach(attribute => {
@@ -27,6 +28,7 @@ const CardAttributeFields = ({ project, form }: Props) => {
key={attribute.id}
attribute={attribute}
form={form}
readOnly={readOnly}
/>
);
});

View File

@@ -8,9 +8,10 @@ import { IconInfoCircle } from "@tabler/icons-react";
type Props = {
attribute: AttributeSchema;
form: UseFormReturnType<CardGeneralFormType>;
readOnly: boolean;
}
const CardAttributeField = ({ attribute, form }: Props) => {
const CardAttributeField = ({ attribute, form, readOnly }: Props) => {
const type = attribute.type.type;
const getDateValue = (): Date | null => {
@@ -35,6 +36,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
<Checkbox
label={label}
{...form.getInputProps(attribute.name, { type: "checkbox" })}
readOnly={readOnly}
/>
);
}
@@ -47,6 +49,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY"
readOnly={readOnly}
/>
);
}
@@ -59,6 +62,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY HH:mm"
readOnly={readOnly}
/>
);
}
@@ -68,6 +72,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
label={label}
{...form.getInputProps(attribute.name)}
value={form.getInputProps(attribute.name).value ?? ""}
readOnly={readOnly}
/>
);
}
@@ -77,6 +82,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
allowDecimal={type === "float"}
label={label}
{...form.getInputProps(attribute.name)}
readOnly={readOnly}
/>
);
}

View File

@@ -8,6 +8,8 @@ import useStatus from "../../Statuses/Status/hooks/useStatus.tsx";
import { useDndContext } from "../../../../pages/CardsPage/contexts/DndContext.tsx";
import DragState from "../../../../pages/CardsPage/enums/DragState.ts";
import { useBoardsContext } from "../../../../contexts/BoardsContext.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type Props = {
@@ -22,6 +24,7 @@ const Board = ({ board }: Props) => {
onDeleteBoardClick,
} = useBoardsContext();
const authState = useSelector((state: RootState) => state.auth);
const { dragState } = useDndContext();
const { showContextMenu } = useContextMenu();
@@ -65,6 +68,7 @@ const Board = ({ board }: Props) => {
<Draggable
draggableId={"board-" + board.id.toString()}
index={board.ordinalNumber}
isDragDisabled={authState.isGuest}
>
{(provided) => (
<div

View File

@@ -8,6 +8,8 @@ import PrefillCardsWithExcelDrawer
from "../../../../pages/CardsPage/drawers/PrefillCardWithExcelDrawer/PrefillCardsWithExcelDrawer.tsx";
import { useDndContext } from "../../../../pages/CardsPage/contexts/DndContext.tsx";
import { useBoardsContext } from "../../../../contexts/BoardsContext.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
const Boards = () => {
@@ -16,6 +18,7 @@ const Boards = () => {
selectedBoard,
onCreateBoardClick,
} = useBoardsContext();
const authState = useSelector((state: RootState) => state.auth);
const {
onDragEnd,
@@ -37,14 +40,16 @@ const Boards = () => {
board={board}
/>
))}
<Center
px={"md"}
py={"xs"}
style={{ cursor: "pointer", borderBottom: "solid gray 1px" }}
onClick={onCreateBoardClick}
>
<IconPlus />
</Center>
{!authState.isGuest && (
<Center
px={"md"}
py={"xs"}
style={{ cursor: "pointer", borderBottom: "solid gray 1px" }}
onClick={onCreateBoardClick}
>
<IconPlus />
</Center>
)}
<Box w={"100%"} style={{ borderBottom: "solid gray 1px" }}></Box>
</Group>
);

View File

@@ -9,6 +9,8 @@ import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import isModuleInProject from "../../../../modules/utils/isModuleInProject.ts";
import CardTags from "../CardTags/CardTags.tsx";
import { ModuleNames } from "../../../../modules/modules.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type Props = {
cards: CardSummary[];
@@ -21,6 +23,7 @@ export const CardGroupView: FC<Props> = ({ cards, group }) => {
const [debouncedName] = useDebouncedValue(name, 200);
const { selectedProject } = useProjectsContext();
const isServicesAndProductsIncluded = isModuleInProject(ModuleNames.SERVICES_AND_PRODUCTS, selectedProject);
const authState = useSelector((state: RootState) => state.auth);
const totalPrice = useMemo(() => cards.reduce((acc, card) => acc + card.totalPrice, 0), [cards]);
const totalProducts = useMemo(() => cards.reduce((acc, card) => acc + card.totalProducts, 0), [cards]);
@@ -77,7 +80,7 @@ export const CardGroupView: FC<Props> = ({ cards, group }) => {
/>
))}
</Flex>
<CardTags groupId={group.id} tags={cards[0].tags}/>
{!authState.isGuest && <CardTags groupId={group.id} tags={cards[0].tags}/>}
{isServicesAndProductsIncluded && (
<Flex
p={rem(10)}
@@ -95,4 +98,4 @@ export const CardGroupView: FC<Props> = ({ cards, group }) => {
)}
</Flex>
);
};
};

View File

@@ -15,6 +15,8 @@ import CardTags from "../CardTags/CardTags.tsx";
import CardAttributesInSummaryItem from "../CardAttributesInSummaryItem/CardAttributesInSummaryItem.tsx";
import { ModuleNames } from "../../../../modules/modules.tsx";
import isDealPaid from "../../../../pages/CardsPage/utils/isDealPaid.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type Props = {
cardSummary: CardSummary;
@@ -27,6 +29,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
const { setSelectedCard } = useCardPageContext();
const { onDelete, onComplete, onDeleteFromGroup } = useCardSummaryState();
const [isPaid, setIsPaid] = useState<boolean>(false);
const authState = useSelector((state: RootState) => state.auth);
const isServicesAndProductsIncluded = isModuleInProject(ModuleNames.SERVICES_AND_PRODUCTS, selectedProject);
const isClientIncluded = isModuleInProject(ModuleNames.CLIENTS, selectedProject);
@@ -36,8 +39,8 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
setSelectedCard(card);
});
};
const isLockedInsideGroup = () => {
return !!(cardSummary?.group?.billRequests && cardSummary?.group?.billRequests?.length !== 0);
const canBeRemovedFromGroup = () => {
return !!(cardSummary.group && (!cardSummary.group.billRequests || cardSummary.group.billRequests?.length === 0));
};
useEffect(() => {
@@ -46,8 +49,8 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
return (
<div
onContextMenu={showContextMenu([
...isLockedInsideGroup() ? [] : [{
onContextMenu={!authState.isDealsViewer ? showContextMenu([
...!canBeRemovedFromGroup() ? [] : [{
key: "removeFromGroup",
onClick: () => onDeleteFromGroup(cardSummary),
title: "Убрать из группы",
@@ -65,7 +68,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
title: "Удалить",
icon: <IconTrash />,
},
])}
]) : undefined}
onClick={() => onCardSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
@@ -108,7 +111,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
</Flex>
)}
<CardAttributesInSummaryItem cardSummary={cardSummary} />
{!cardSummary.group?.id && (
{!authState.isGuest && !cardSummary.group?.id && (
<CardTags cardId={cardSummary.id} tags={cardSummary.tags} />
)}
<Flex align={"center"} justify={"space-between"}>

View File

@@ -12,6 +12,8 @@ import DragState from "../../../../pages/CardsPage/enums/DragState.ts";
import isModuleInProject from "../../../../modules/utils/isModuleInProject.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { ModuleNames } from "../../../../modules/modules.tsx";
import { RootState } from "../../../../redux/store.ts";
import { useSelector } from "react-redux";
type Props = {
status: StatusSchema;
@@ -28,6 +30,7 @@ export const CardsDndColumn: FC<Props> = ({
dragState,
withCreateButton = false,
}) => {
const authState = useSelector((state: RootState) => state.auth);
const { selectedProject } = useProjectsContext();
const isCreatingDealFromFileEnabled = isModuleInProject(ModuleNames.SERVICES_AND_PRODUCTS, selectedProject);
const isDropDisabled = dragState !== DragState.DRAG_CARD;
@@ -77,7 +80,9 @@ export const CardsDndColumn: FC<Props> = ({
<Draggable
draggableId={card.id.toString()}
index={card.rank}
key={card.id}>
key={card.id}
isDragDisabled={authState.isGuest}
>
{(provided, snapshot) => (
<div
{...provided.draggableProps}
@@ -104,6 +109,7 @@ export const CardsDndColumn: FC<Props> = ({
draggableId={"group-" + group.id}
index={cards[0].rank}
key={"group-" + group.id}
isDragDisabled={authState.isGuest}
>
{(provided) => (
<div
@@ -135,7 +141,7 @@ export const CardsDndColumn: FC<Props> = ({
styles["items-list-drag-over"],
)}
{...provided.droppableProps}>
{withCreateButton && (
{withCreateButton && !authState.isGuest && (
<>
<CreateCardButton status={status} />
{

View File

@@ -10,6 +10,7 @@ import { usePrefillCardContext } from "../../../../pages/CardsPage/contexts/Pref
import isModuleInProject from "../../../../modules/utils/isModuleInProject.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { ModuleNames } from "../../../../modules/modules.tsx";
import { useDndContext } from "../../../../pages/CardsPage/contexts/DndContext.tsx";
type Props = {
status: StatusSchema;
@@ -20,6 +21,7 @@ const CreateCardButton = ({ status }: Props) => {
const [isTransitionEnded, setIsTransitionEnded] = useState(true);
const queryClient = useQueryClient();
const { prefillCard, setPrefillCard } = usePrefillCardContext();
const { refetchSummaries } = useDndContext();
const { selectedProject } = useProjectsContext();
const isPrefillingDealEnabled = isModuleInProject(ModuleNames.SERVICES_AND_PRODUCTS, selectedProject);
@@ -57,7 +59,7 @@ const CreateCardButton = ({ status }: Props) => {
},
}).then(async (result) => {
if (isPrefillingDealEnabled && prefillCard) {
CardService.prefillCard({
await CardService.prefillCard({
requestBody: {
oldCardId: prefillCard.id,
newCardId: result.cardId,
@@ -67,6 +69,7 @@ const CreateCardButton = ({ status }: Props) => {
await queryClient.invalidateQueries({
queryKey: ["getCardSummaries"],
});
refetchSummaries();
setIsCreating(false);
setPrefillCard(undefined);
});

View File

@@ -13,6 +13,8 @@ import isModuleInProject from "../../../../modules/utils/isModuleInProject.ts";
import { useEqualHeightsContext } from "./contexts/EqualHeightContext.tsx";
import { useBoardsContext } from "../../../../contexts/BoardsContext.tsx";
import { ModuleNames } from "../../../../modules/modules.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type Props = {
@@ -26,6 +28,7 @@ const Status = ({ summaries, status, dragState, index }: Props) => {
const {
selectedBoard,
} = useBoardsContext();
const authState = useSelector((state: RootState) => state.auth);
const isDropDisabled = dragState !== DragState.DRAG_STATUS;
const isServicesAndProductsIncluded = isModuleInProject(ModuleNames.SERVICES_AND_PRODUCTS, selectedBoard?.project);
@@ -107,6 +110,7 @@ const Status = ({ summaries, status, dragState, index }: Props) => {
<Draggable
draggableId={"status-" + status.id.toString()}
index={status.ordinalNumber}
isDragDisabled={authState.isGuest}
>
{(provided) => (
<div

View File

@@ -4,6 +4,8 @@ import { jwtDecode, JwtPayload as JwtPayloadBase } from "jwt-decode";
interface AuthState {
isAuthorized: boolean;
accessToken: string;
isDealEditor: boolean;
isDealsViewer: boolean;
isGuest: boolean;
role: string;
}
@@ -16,6 +18,8 @@ const initialState = (): AuthState => {
return {
accessToken: "",
isAuthorized: false,
isDealEditor: false,
isDealsViewer: false,
isGuest: false,
role: "user",
};
@@ -37,7 +41,9 @@ const authSlice = createSlice({
state.accessToken = action.payload.accessToken;
state.isAuthorized = true;
state.role = role;
if (sub === "guest") state.isGuest = true;
state.isDealEditor = sub === "deal_editor";
state.isDealsViewer = sub === "deals_viewer";
state.isGuest = state.isDealEditor || state.isDealsViewer;
} catch (_) {
const url = window.location.href;
const urlObj = new URL(url);

View File

@@ -21,11 +21,16 @@ import PrintDealBarcodesButton from "./components/PrintDealBarcodesButton/PrintD
import PaymentLinkButton from "./components/PaymentLinkButton/PaymentLinkButton.tsx";
import isValidInn from "../../../../pages/ClientsPage/utils/isValidInn.ts";
import isDealPaid, { isDealLocked } from "../../../../pages/CardsPage/utils/isDealPaid.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
import { useCardPageContext } from "../../../../pages/CardsPage/contexts/CardPageContext.tsx";
const ProductAndServiceTab: FC = () => {
const { cardState, cardServicesState, cardProductsState } = useCardProductAndServiceTabState();
const { selectedCard: card } = useCardPageContext();
const isLocked = isDealLocked(cardState.card);
const [paid, setPaid] = useState<boolean>(false);
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
useEffect(() => {
setPaid(isDealPaid(cardState.card));
@@ -128,7 +133,9 @@ const ProductAndServiceTab: FC = () => {
ProductService.createProduct({
requestBody: newProduct,
}).then(({ ok, message }) => {
notifications.guess(ok, { message: message });
notifications.guess(ok, { message });
}).catch(err => {
notifications.error({ message: err.toString() });
});
};
const onCreateProductClick = () => {
@@ -256,18 +263,18 @@ const ProductAndServiceTab: FC = () => {
mr={"xs"}
>
<Group wrap={"nowrap"}>
<PrintDealBarcodesButton card={cardState.card} />
{!isDealsViewer && <PrintDealBarcodesButton card={cardState.card} />}
<Checkbox
label={"Оплачен"}
checked={paid}
disabled
/>
</Group>
<PaymentLinkButton card={cardState.card} />
{!isDealsViewer && <PaymentLinkButton card={cardState.card} />}
</Group>
)}
<Stack className={styles["card-container-wrapper"]} mr={"xs"}>
<GeneralDataForm />
{card && <GeneralDataForm />}
</Stack>
<ScrollArea offsetScrollbars>
<Flex
@@ -278,41 +285,45 @@ const ProductAndServiceTab: FC = () => {
{...cardServicesState}
/>
<Divider my={rem(15)} />
<div className={styles["card-container-buttons"]}>
<Button
disabled={isLocked}
variant={"default"}
fullWidth
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
disabled={isLocked}
onClick={onAddProductClick}
variant={"default"}
fullWidth>
Добавить товар
</Button>
</div>
<Divider my={rem(15)} />
<div className={styles["card-container-buttons"]}>
{isLocked ? (
<Button
onClick={onCancelBillClick}
color={"red"}>
Отозвать счет
</Button>
) : (
<Button
disabled={isLocked}
onClick={onCreateBillClick}
variant={"default"}
fullWidth>
Выставить счет
</Button>
)}
</div>
{!isDealsViewer && (
<>
<Divider my={rem(15)} />
<div className={styles["card-container-buttons"]}>
<Button
disabled={isLocked}
variant={"default"}
fullWidth
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
disabled={isLocked}
onClick={onAddProductClick}
variant={"default"}
fullWidth>
Добавить товар
</Button>
</div>
<Divider my={rem(15)} />
<div className={styles["card-container-buttons"]}>
{isLocked ? (
<Button
onClick={onCancelBillClick}
color={"red"}>
Отозвать счет
</Button>
) : (
<Button
disabled={isLocked}
onClick={onCreateBillClick}
variant={"default"}
fullWidth>
Выставить счет
</Button>
)}
</div>
</>
)}
</Flex>
<Flex
direction={"column"}

View File

@@ -138,15 +138,17 @@ const CardServicesTable: FC<Props> = ({
w={"100%"}
gap={rem(10)}
align={"center"}>
<Tooltip
onClick={() => onDeleteClick(service)}
label="Удалить услугу">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
{!authState.isDealsViewer && (
<Tooltip
onClick={() => onDeleteClick(service)}
label="Удалить услугу">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
)}
{!authState.isGuest && (
<Tooltip label="Сотрудники">
<ActionIcon
@@ -167,9 +169,8 @@ const CardServicesTable: FC<Props> = ({
isNumber(event) &&
onQuantityChange(service, event)
}
value={service.quantity}
readOnly={authState.isDealsViewer}
/>
<NumberInput
flex={1}
@@ -180,6 +181,7 @@ const CardServicesTable: FC<Props> = ({
suffix={"₽"}
value={service.price}
disabled={authState.isGuest || isLocked || service.isFixedPrice}
readOnly={authState.isDealsViewer}
rightSectionProps={{
style: {
display: "flex",
@@ -188,12 +190,14 @@ const CardServicesTable: FC<Props> = ({
},
}}
rightSection={
<LockCheckbox
label={"Зафиксировать цену"}
variant={"default"}
value={service.isFixedPrice}
onChange={value => onLockChange(service, value)}
/>
!authState.isDealsViewer && (
<LockCheckbox
label={"Зафиксировать цену"}
variant={"default"}
value={service.isFixedPrice}
onChange={value => onLockChange(service, value)}
/>
)
}
/>
</Flex>
@@ -212,25 +216,27 @@ const CardServicesTable: FC<Props> = ({
</Title>
</Flex>
<Flex
direction={"column"}
gap={rem(10)}
mt={"auto"}>
<Button
disabled={isLocked}
onClick={onCreateClick}
fullWidth
variant={"default"}>
Добавить услугу
</Button>
<Button
disabled={isLocked}
onClick={onAddKitClick}
fullWidth
variant={"default"}>
Добавить набор услуг
</Button>
</Flex>
{!authState.isDealsViewer && (
<Flex
direction={"column"}
gap={rem(10)}
mt={"auto"}>
<Button
disabled={isLocked}
onClick={onCreateClick}
fullWidth
variant={"default"}>
Добавить услугу
</Button>
<Button
disabled={isLocked}
onClick={onAddKitClick}
fullWidth
variant={"default"}>
Добавить набор услуг
</Button>
</Flex>
)}
</Flex>
<Modal
title={"Добавление сотрудника к услуге"}

View File

@@ -7,6 +7,8 @@ import { Button, Checkbox, Stack } from "@mantine/core";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { useEffect, useState } from "react";
import { isEqual } from "lodash";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
type GeneralDataFormType = {
@@ -16,7 +18,8 @@ type GeneralDataFormType = {
const GeneralDataForm = () => {
const { selectedCard: card, refetchCard } = useCardPageContext();
if (!card) return;
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
if (!card) return <></>;
const [initialValues, setInitialValues] = useState<GeneralDataFormType>(card);
@@ -67,7 +70,7 @@ const GeneralDataForm = () => {
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<ShippingWarehouseAutocomplete
placeholder={"Введите склад отгрузки"}
placeholder={isDealsViewer ? "" : "Введите склад отгрузки"}
label={"Склад отгрузки"}
value={
isShippingWarehouse(
@@ -87,18 +90,23 @@ const GeneralDataForm = () => {
"shippingWarehouse",
).onChange(event);
}}
readOnly={isDealsViewer}
/>
<Checkbox
label={"Учет выручки в статистике"}
{...form.getInputProps("isServicesProfitAccounted", { type: "checkbox" })}
/>
<Button
type={"submit"}
variant={"default"}
disabled={isEqual(initialValues, form.values)}
>
Сохранить
</Button>
{!isDealsViewer && (
<>
<Checkbox
label={"Учет выручки в статистике"}
{...form.getInputProps("isServicesProfitAccounted", { type: "checkbox" })}
/>
<Button
type={"submit"}
variant={"default"}
disabled={isEqual(initialValues, form.values)}
>
Сохранить
</Button>
</>
)}
</Stack>
</form>
);

View File

@@ -103,8 +103,8 @@ const ProductServicesTable: FC<Props> = ({
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: true,
enableBottomToolbar: true,
enableRowActions: !authState.isDealsViewer,
enableBottomToolbar: !authState.isDealsViewer,
renderBottomToolbar: (
<Flex
justify={"flex-end"}

View File

@@ -15,6 +15,8 @@ import { ServiceType } from "../../../../../../shared/enums/ServiceType.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
import { isDealLocked } from "../../../../../../pages/CardsPage/utils/isDealPaid.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
type Props = {
product: CardProductSchema;
@@ -54,6 +56,8 @@ const ProductView: FC<Props> = ({
onDelete(product);
};
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
const onServiceDelete = (item: CardProductServiceSchema) => {
if (!onChange) return;
onChange({
@@ -162,25 +166,29 @@ const ProductView: FC<Props> = ({
isNumber(event) && onQuantityChange(event)
}
placeholder={"Введите количество товара"}
readOnly={isDealsViewer}
/>
<Textarea
mih={rem(140)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
my={rem(10)}
disabled={isLocked}
defaultValue={product.comment}
onChange={event => {
if (!onChange) return;
debouncedOnChange({
...product,
comment: event.currentTarget.value,
});
}}
placeholder={"Введите комментарий для товара"}
/>
{!(isDealsViewer && product.comment?.length === 0) && (
<Textarea
mih={rem(140)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
my={rem(10)}
disabled={isLocked}
defaultValue={product.comment}
onChange={event => {
if (!onChange) return;
debouncedOnChange({
...product,
comment: event.currentTarget.value,
});
}}
placeholder={isDealsViewer ? "" : "Введите комментарий для товара"}
readOnly={isDealsViewer}
/>
)}
</div>
<div className={styles["services-container"]}>
@@ -195,34 +203,36 @@ const ProductView: FC<Props> = ({
onDelete={onServiceDelete}
onChange={onServiceChange}
/>
<Flex
mt={"auto"}
ml={"auto"}
gap={rem(10)}>
<Tooltip
onClick={onPrintBarcodeClick}
label="Печать штрихкода">
<ActionIcon variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip
onClick={onProductEditClick}
label="Редактировать товар">
<ActionIcon variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
<Tooltip
onClick={onDeleteClick}
label="Удалить товар">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
{!isDealsViewer && (
<Flex
mt={"auto"}
ml={"auto"}
gap={rem(10)}>
<Tooltip
onClick={onPrintBarcodeClick}
label="Печать штрихкода">
<ActionIcon variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip
onClick={onProductEditClick}
label="Редактировать товар">
<ActionIcon variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
<Tooltip
onClick={onDeleteClick}
label="Удалить товар">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
)}
</div>
</div>
);

View File

@@ -1,7 +1,9 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
import { useProjectsContext } from "../../contexts/ProjectsContext.tsx";
import { MODULES } from "../modules.tsx";
import { ModuleNames, MODULES } from "../modules.tsx";
import { Module } from "../types.tsx";
import { RootState } from "../../redux/store.ts";
import { useSelector } from "react-redux";
type ModulesContextState = {
modules: Module[];
@@ -13,15 +15,24 @@ const ModulesContext = createContext<ModulesContextState | undefined>(
const useModulesContextState = () => {
const { selectedProject } = useProjectsContext();
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
const [modules, setModules] = useState<Module[]>([]);
const filterModules = (modulesToFilter: Module[]): Module[] => {
if (isDealsViewer) {
const modulesForDealsViewer: string[] = [ModuleNames.SERVICES_AND_PRODUCTS];
return modulesToFilter.filter(module => modulesForDealsViewer.includes(module.info.key));
}
return modulesToFilter;
};
useEffect(() => {
const modules = selectedProject?.modules ?? [];
const projectModules = modules.map(module => {
return MODULES[module.key];
}) ?? [];
setModules(projectModules);
setModules(filterModules(projectModules));
}, [selectedProject?.id]);
return {

View File

@@ -22,42 +22,42 @@ const modules: ModulesType = {
[ModuleNames.CLIENTS]: {
info: {
label: "Клиенты",
key: "clients",
key: ModuleNames.CLIENTS,
icon: <IconUser />,
}
},
[ModuleNames.SERVICES_AND_PRODUCTS]: {
info: {
label: "Товары и услуги",
key: "servicesAndProducts",
key: ModuleNames.SERVICES_AND_PRODUCTS,
icon: <IconBox />,
}
},
[ModuleNames.SHIPMENT]: {
info: {
label: "Отгрузка",
key: "shipment",
key: ModuleNames.SHIPMENT,
icon: <IconCubeSend />,
}
},
[ModuleNames.EMPLOYEES]: {
info: {
label: "Сотрудники",
key: "employees",
key: ModuleNames.EMPLOYEES,
icon: <IconUsersGroup />,
}
},
[ModuleNames.MANAGERS]: {
info: {
label: "Менеджер",
key: "managers",
key: ModuleNames.MANAGERS,
icon: <IconUserCog />,
}
},
[ModuleNames.CHAT]: {
info: {
label: "Чат",
key: "chat",
key: ModuleNames.CHAT,
icon: <IconMessage />,
}
},

View File

@@ -11,6 +11,7 @@ type DndContextState = {
dragState: DragState,
onDragStart: (start: DragStart) => void,
onDragEnd: (result: DropResult) => Promise<void>,
refetchSummaries: () => void;
};
const DndContext = createContext<DndContextState | undefined>(undefined);
@@ -64,6 +65,7 @@ const useDndContextState = ({
dragState,
onDragStart,
onDragEnd,
refetchSummaries,
};
};

View File

@@ -26,6 +26,8 @@ import CardStatusSelect from "../../../../../../components/CardStatusSelect/Card
import CardAttributeFields from "../../../../../../components/CardAttributeFields/CardAttributeFields.tsx";
import getAttributesFromCard from "../../../../../../components/CardAttributeFields/utils/getAttributesFromCard.ts";
import CardTagsInput from "../../../../components/CardTagsInput/CardTagsInput.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
type Props = {
card: CardSchema;
@@ -43,6 +45,7 @@ const Content: FC<Props> = ({ card }) => {
const queryClient = useQueryClient();
const [project, setProject] = useState<ProjectSchema | null>(card.board.project);
const [cardTags, setCardTags] = useState<string[]>(card.tags?.map(tag => tag.name) ?? []);
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
const getInitialValues = (card: CardSchema): CardGeneralFormType => {
return {
@@ -159,6 +162,7 @@ const Content: FC<Props> = ({ card }) => {
placeholder={"Название сделки"}
label={"Название сделки"}
{...form.getInputProps("name")}
readOnly={isDealsViewer}
/>
<TextInput
disabled
@@ -178,11 +182,13 @@ const Content: FC<Props> = ({ card }) => {
project={project}
{...form.getInputProps("board")}
label={"Доска"}
readOnly={isDealsViewer}
/>
<CardStatusSelect
board={form.values.board}
{...form.getInputProps("status")}
label={"Статус"}
readOnly={isDealsViewer}
/>
<Textarea
h={rem(120)}
@@ -191,73 +197,78 @@ const Content: FC<Props> = ({ card }) => {
input: { height: "90%" },
}}
label={"Коментарий"}
placeholder={"Введите коментарий"}
placeholder={isDealsViewer ? "" : "Введите коментарий"}
{...form.getInputProps("comment")}
readOnly={isDealsViewer}
/>
{project && project?.tags.length > 0 && (
<CardTagsInput
value={cardTags}
onChange={setCardTags}
readOnly={isDealsViewer}
/>
)}
{project && (
<CardAttributeFields
project={project}
form={form}
readOnly={isDealsViewer}
/>
)}
</Flex>
</Fieldset>
</Stack>
</ScrollArea>
<Flex
mt={"md"}
gap={rem(10)}
align={"center"}
justify={"flex-end"}>
{!isDealsViewer && (
<Flex
align={"center"}
mt={"md"}
gap={rem(10)}
justify={"center"}>
<ButtonCopyControlled
onCopyClick={onCopyGuestUrlClick}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
copied={clipboard.copied}
>
Ссылка на редактирование
</ButtonCopyControlled>
<Flex gap={rem(10)}>
<Checkbox
label={"Завершена"}
{...form.getInputProps("isCompleted", { type: "checkbox" })}
/>
<Checkbox
label={"Удалена"}
{...form.getInputProps("isDeleted", { type: "checkbox" })}
/>
</Flex>
</Flex>
<Divider orientation={"vertical"} />
<Group
align={"center"}
justify={"center"}>
<Button
color={"red"}
type={"reset"}
disabled={isEqualValues()}
onClick={cancelChanges}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqualValues()}>
Сохранить изменения
</Button>
</Group>
</Flex>
justify={"flex-end"}>
<Flex
align={"center"}
gap={rem(10)}
justify={"center"}>
<ButtonCopyControlled
onCopyClick={onCopyGuestUrlClick}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
copied={clipboard.copied}
>
Ссылка на редактирование
</ButtonCopyControlled>
<Flex gap={rem(10)}>
<Checkbox
label={"Завершена"}
{...form.getInputProps("isCompleted", { type: "checkbox" })}
/>
<Checkbox
label={"Удалена"}
{...form.getInputProps("isDeleted", { type: "checkbox" })}
/>
</Flex>
</Flex>
<Divider orientation={"vertical"} />
<Group
align={"center"}
justify={"center"}>
<Button
color={"red"}
type={"reset"}
disabled={isEqualValues()}
onClick={cancelChanges}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqualValues()}>
Сохранить изменения
</Button>
</Group>
</Flex>
)}
</Flex>
</form>
);

View File

@@ -10,6 +10,8 @@ import { useDisclosure } from "@mantine/hooks";
import InlineButton from "../../../components/InlineButton/InlineButton.tsx";
import { IconFilter } from "@tabler/icons-react";
import BoardSelect from "../../../components/BoardSelect/BoardSelect.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
type Props = {
form: UseFormReturnType<CardsPageState>;
@@ -18,6 +20,7 @@ type Props = {
const CardsTableFiltersModal = ({ form, projects }: Props) => {
const [opened, { open, close }] = useDisclosure();
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
return (
<>
@@ -60,15 +63,17 @@ const CardsTableFiltersModal = ({ form, projects }: Props) => {
placeholder={"Выберите маркетплейс"}
{...form.getInputProps("marketplace")}
/>
<ClientSelectNew
onClear={() =>
form.setFieldValue("client", null)
}
clearable
searchable
placeholder={"Выберите клиента"}
{...form.getInputProps("client")}
/>
{!isDealsViewer && (
<ClientSelectNew
onClear={() =>
form.setFieldValue("client", null)
}
clearable
searchable
placeholder={"Выберите клиента"}
{...form.getInputProps("client")}
/>
)}
</Flex>
</Modal>
</>

View File

@@ -1,5 +1,4 @@
import { FC } from "react";
import ClientsTable from "./components/ClientsTable/ClientsTable.tsx";
import useClientsList from "./hooks/useClientsList.tsx";
import PageBlock from "../../components/PageBlock/PageBlock.tsx";
import styles from "./ClientsPage.module.css";
@@ -7,9 +6,10 @@ import { Button, TextInput } from "@mantine/core";
import { modals } from "@mantine/modals";
import { ClientSchema, ClientService } from "../../client";
import { notifications } from "../../shared/lib/notifications.ts";
import { ChatContextProvider } from "./contexts/ChatContext.tsx";
import ClientChatDrawer from "./drawers/ClientChatDrawer/ClientChatDrawer.tsx";
import useClientsFilter from "./hooks/useClientsFilter.tsx";
import { ChatContextProvider } from "./contexts/ChatContext.tsx";
import ClientsTable from "./components/ClientsTable/ClientsTable.tsx";
import ClientChatDrawer from "./drawers/ClientChatDrawer/ClientChatDrawer.tsx";
const ClientsPage: FC = () => {
const { clients, refetch } = useClientsList();

View File

@@ -0,0 +1,57 @@
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { IconCheck, IconLink } from "@tabler/icons-react";
import { FC } from "react";
import { useClipboard } from "@mantine/hooks";
import { ClientSchema, ClientService } from "../../../../client";
import { notifications } from "../../../../shared/lib/notifications.ts";
type Props = {
client: ClientSchema;
};
const ClientLinkActionIcon: FC<Props> = ({ client }) => {
const clipboard = useClipboard();
const onGetLinkClick = () => {
ClientService.clientLink({
clientId: client.id,
})
.then(({ ok, message, url }) => {
if (ok) {
clipboard.copy(`${window.location.origin}/${url}`);
} else {
notifications.error({ message });
}
})
.catch(err => console.log(err));
};
return (
<Tooltip
label={"Ссылка скопирована"}
offset={5}
radius="xl"
transitionProps={{ duration: 100, transition: "slide-down" }}
opened={clipboard.copied}>
<ActionIcon
variant={"default"}
size="md"
onClick={() => onGetLinkClick()}
>
{clipboard.copied ? (
<IconCheck
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
) : (
<IconLink
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
)}
</ActionIcon>
</Tooltip>
);
};
export default ClientLinkActionIcon;

View File

@@ -8,6 +8,7 @@ import { IconEdit, IconTrash } from "@tabler/icons-react";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { modals } from "@mantine/modals";
import ClientChatButton from "../ClientChatButton/ClientChatButton.tsx";
import ClientLinkActionIcon from "../ClientLinkActionIcon/ClientLinkActionIcon.tsx";
type RefetchProps = {
refetch: () => void;
@@ -46,7 +47,8 @@ const ClientsTable: FC<CRUDTableProps<ClientSchema> & RefetchProps> = ({
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<ClientChatButton client={row.original} refetch={refetch}/>
<ClientChatButton client={row.original} refetch={refetch} />
<ClientLinkActionIcon client={row.original} />
<Tooltip label="Редактировать">
<ActionIcon
onClick={() =>

View File

@@ -8,11 +8,16 @@ type Props = {
const useClientsFilter = ({ clients }: Props) => {
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebouncedValue(search, 400);
const [debouncedSearch] = useDebouncedValue(search, 400);
const [filteredClients, setFilteredClients] = useState<ClientSchema[]>([]);
const filterClients = () => {
const loweredSearch = search.toLowerCase();
if (debouncedSearch.length === 0) {
setFilteredClients(clients);
return;
}
const loweredSearch = debouncedSearch.toLowerCase();
const filtered = clients.filter(
client => (
client.name.toLowerCase().includes(loweredSearch) ||
@@ -28,7 +33,7 @@ const useClientsFilter = ({ clients }: Props) => {
useEffect(() => {
filterClients();
}, [debouncedSearch]);
}, [debouncedSearch, clients]);
return {
search,