Compare commits

...

29 Commits

Author SHA1 Message Date
27f27f4c38 feat: update CardAttributeField to handle date values without timezone 2025-09-18 16:24:39 +03:00
6e19d98f81 feat: update ProductSelect to use server-side search value for improved filtering 2025-09-07 00:02:29 +03:00
7a052f7422 feat: enhance product filtering in ProductSelect component 2025-09-05 04:42:08 +03:00
b08f921c1c add .dockerignore to exclude node_modules from Docker builds 2025-09-02 06:13:41 +03:00
d0da5155a1 add .dockerignore to exclude node_modules from Docker builds 2025-09-02 06:12:09 +03:00
bdbdd10231 feat: refactor ClientSelect to use ObjectSelect for improved client selection 2025-09-02 06:11:24 +03:00
9dde9041b1 add build script for Docker and update .gitignore to exclude PDF files 2025-09-02 05:49:02 +03:00
01260d0768 feat: add Dockerfile and Nginx configuration for deployment 2025-09-02 03:38:24 +03:00
15a085721e fix: client delete fix 2025-07-18 19:28:27 +04:00
655ae077ca feat: logout button in the deals viewer mode 2025-07-14 22:31:56 +04:00
50590fa41c feat: no project selecting for clients 2025-07-14 09:52:36 +04:00
c866231730 fix: data loading after login fixed 2025-07-04 21:12:01 +04:00
c3d135eba9 feat: deals viewer mode and links for viewers 2025-07-04 15:12:37 +04:00
82e2ef6db2 fix: client phone number setting fixed 2025-06-28 13:12:09 +04:00
4787151b69 fix: client empty phone number enabled 2025-06-28 12:54:28 +04:00
eea03bd70a fix: cards reorder fixed 2025-06-23 16:57:31 +04:00
f7d514df4b feat: clients search 2025-05-16 12:19:53 +04:00
b09479302b feat: warehouse place number editing 2025-05-13 15:54:33 +04:00
74665e123b fix: product quantity edit fix 2025-05-12 14:57:39 +04:00
4fee2da42c fix: removing from group fixed 2025-05-12 01:06:51 +04:00
cc39d13b1c fix: modal for place qr code cards, deal services editing 2025-05-12 00:42:49 +04:00
2a7a50886c fix: wms modals without titles 2025-05-11 22:17:46 +04:00
3b082d7836 fix: worktime table columns order fix 2025-05-09 16:24:24 +04:00
37136b83bc feat: worktime table with dates range 2025-05-09 16:19:29 +04:00
d03ba66ebb feat: split bills for one deal 2025-05-09 13:33:17 +04:00
e4f8e90ae6 feat: warehouse places accounting 2025-05-07 09:53:02 +04:00
d347c09199 feat: chats list on the left of the client chat 2025-04-16 13:58:02 +04:00
f28df5a074 Merge remote-tracking branch 'origin/master' 2025-04-15 11:00:27 +04:00
9ce112fd63 fix: cards fetch optimization 2025-04-15 10:59:56 +04:00
127 changed files with 4406 additions and 1147 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM oven/bun:latest AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
RUN bun run build
FROM nginx:alpine
COPY --from=prerelease /usr/src/app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2
build-docker.sh Executable file
View File

@@ -0,0 +1,2 @@
docker build -t git.denco.store/fakz9/fulfillment-frontend:latest .
docker push git.denco.store/fakz9/fulfillment-frontend:latest

1188
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

12
nginx.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
sendfile on;
}
}

View File

@@ -32,6 +32,9 @@ export type { BaseEnumListSchema } from './models/BaseEnumListSchema';
export type { BaseEnumSchema } from './models/BaseEnumSchema';
export type { BaseMarketplaceSchema } from './models/BaseMarketplaceSchema';
export type { BaseMessageSchema } from './models/BaseMessageSchema';
export type { BasePlaceSchema } from './models/BasePlaceSchema';
export type { BasePlaceTypeSchema } from './models/BasePlaceTypeSchema';
export type { BasePlaceTypeWithCountSchema } from './models/BasePlaceTypeWithCountSchema';
export type { BaseProjectSchema } from './models/BaseProjectSchema';
export type { BaseShippingWarehouseSchema } from './models/BaseShippingWarehouseSchema';
export type { BaseStatusSchema } from './models/BaseStatusSchema';
@@ -117,6 +120,7 @@ export type { CardUpdateServiceQuantityResponse } from './models/CardUpdateServi
export type { CardUpdateServiceRequest } from './models/CardUpdateServiceRequest';
export type { CardUpdateServiceResponse } from './models/CardUpdateServiceResponse';
export type { ChatSchema } from './models/ChatSchema';
export type { ChatsListItemSchema } from './models/ChatsListItemSchema';
export type { CityBreakdownFromExcelSchema } from './models/CityBreakdownFromExcelSchema';
export type { ClientCreateRequest } from './models/ClientCreateRequest';
export type { ClientCreateResponse } from './models/ClientCreateResponse';
@@ -151,6 +155,7 @@ export type { CreateDepartmentRequest } from './models/CreateDepartmentRequest';
export type { CreateDepartmentResponse } from './models/CreateDepartmentResponse';
export type { CreateDepartmentSectionRequest } from './models/CreateDepartmentSectionRequest';
export type { CreateDepartmentSectionResponse } from './models/CreateDepartmentSectionResponse';
export type { CreateGuestUrlResponse } from './models/CreateGuestUrlResponse';
export type { CreateMarketplaceRequest } from './models/CreateMarketplaceRequest';
export type { CreateMarketplaceResponse } from './models/CreateMarketplaceResponse';
export type { CreatePalletResponse } from './models/CreatePalletResponse';
@@ -158,6 +163,10 @@ export type { CreatePaymentRecordRequest } from './models/CreatePaymentRecordReq
export type { CreatePaymentRecordResponse } from './models/CreatePaymentRecordResponse';
export type { CreatePayRateRequest } from './models/CreatePayRateRequest';
export type { CreatePayRateResponse } from './models/CreatePayRateResponse';
export type { CreatePlaceRequest } from './models/CreatePlaceRequest';
export type { CreatePlaceResponse } from './models/CreatePlaceResponse';
export type { CreatePlaceTypeRequest } from './models/CreatePlaceTypeRequest';
export type { CreatePlaceTypeResponse } from './models/CreatePlaceTypeResponse';
export type { CreatePositionRequest } from './models/CreatePositionRequest';
export type { CreatePositionResponse } from './models/CreatePositionResponse';
export type { CreateProjectRequest } from './models/CreateProjectRequest';
@@ -196,6 +205,8 @@ export type { DeletePaymentRecordRequest } from './models/DeletePaymentRecordReq
export type { DeletePaymentRecordResponse } from './models/DeletePaymentRecordResponse';
export type { DeletePayRateRequest } from './models/DeletePayRateRequest';
export type { DeletePayRateResponse } from './models/DeletePayRateResponse';
export type { DeletePlaceResponse } from './models/DeletePlaceResponse';
export type { DeletePlaceTypeResponse } from './models/DeletePlaceTypeResponse';
export type { DeletePositionRequest } from './models/DeletePositionRequest';
export type { DeletePositionResponse } from './models/DeletePositionResponse';
export type { DeleteProjectResponse } from './models/DeleteProjectResponse';
@@ -220,10 +231,16 @@ export type { DepartmentSectionSchema } from './models/DepartmentSectionSchema';
export type { EditMessageRequest } from './models/EditMessageRequest';
export type { EditMessageResponse } from './models/EditMessageResponse';
export type { EditMessageSchema } from './models/EditMessageSchema';
export type { EditPlaceRequest } from './models/EditPlaceRequest';
export type { EditPlaceResponse } from './models/EditPlaceResponse';
export type { EditPlaceSchema } from './models/EditPlaceSchema';
export type { EditPlaceTypeRequest } from './models/EditPlaceTypeRequest';
export type { EditPlaceTypeResponse } from './models/EditPlaceTypeResponse';
export type { FinishPauseByShiftIdResponse } from './models/FinishPauseByShiftIdResponse';
export type { FinishPauseByUserIdResponse } from './models/FinishPauseByUserIdResponse';
export type { FinishShiftByIdResponse } from './models/FinishShiftByIdResponse';
export type { FinishShiftResponse } from './models/FinishShiftResponse';
export type { FlatPlaceTypeSchema } from './models/FlatPlaceTypeSchema';
export type { FullProjectSchema } from './models/FullProjectSchema';
export type { GenerateInviteCodeRequest } from './models/GenerateInviteCodeRequest';
export type { GenerateInviteCodeResponse } from './models/GenerateInviteCodeResponse';
@@ -252,16 +269,21 @@ export type { GetBoardsResponse } from './models/GetBoardsResponse';
export type { GetCardBillById } from './models/GetCardBillById';
export type { GetCardProductsBarcodesPdfRequest } from './models/GetCardProductsBarcodesPdfRequest';
export type { GetCardProductsBarcodesPdfResponse } from './models/GetCardProductsBarcodesPdfResponse';
export type { GetCardSummariesRequest } from './models/GetCardSummariesRequest';
export type { GetChatRequest } from './models/GetChatRequest';
export type { GetChatResponse } from './models/GetChatResponse';
export type { GetChatsListResponse } from './models/GetChatsListResponse';
export type { GetClientMarketplacesRequest } from './models/GetClientMarketplacesRequest';
export type { GetClientMarketplacesResponse } from './models/GetClientMarketplacesResponse';
export type { GetDepartmentSectionsResponse } from './models/GetDepartmentSectionsResponse';
export type { GetDepartmentsResponse } from './models/GetDepartmentsResponse';
export type { GetFlatPlaceTypesResponse } from './models/GetFlatPlaceTypesResponse';
export type { GetManagersResponse } from './models/GetManagersResponse';
export type { GetMessagesRequest } from './models/GetMessagesRequest';
export type { GetMessagesResponse } from './models/GetMessagesResponse';
export type { GetPaymentRecordsResponse } from './models/GetPaymentRecordsResponse';
export type { GetPlacesResponse } from './models/GetPlacesResponse';
export type { GetPlaceTypesResponse } from './models/GetPlaceTypesResponse';
export type { GetPlannedWorkShiftsResponse } from './models/GetPlannedWorkShiftsResponse';
export type { GetProductBarcodePdfRequest } from './models/GetProductBarcodePdfRequest';
export type { GetProductBarcodePdfResponse } from './models/GetProductBarcodePdfResponse';
@@ -309,6 +331,8 @@ export type { PayRateSchema } from './models/PayRateSchema';
export type { PayRateSchemaBase } from './models/PayRateSchemaBase';
export type { PayrollSchemeSchema } from './models/PayrollSchemeSchema';
export type { PermissionSchema } from './models/PermissionSchema';
export type { PlaceSchema } from './models/PlaceSchema';
export type { PlaceTypeSchema } from './models/PlaceTypeSchema';
export type { PlannedWorkShiftSchema } from './models/PlannedWorkShiftSchema';
export type { PlanningTableRow } from './models/PlanningTableRow';
export type { PositionSchema } from './models/PositionSchema';
@@ -342,6 +366,7 @@ export type { ProjectGeneralInfoSchema } from './models/ProjectGeneralInfoSchema
export type { ProjectSchema } from './models/ProjectSchema';
export type { ReceiptBoxSchema } from './models/ReceiptBoxSchema';
export type { ReceiptPalletSchema } from './models/ReceiptPalletSchema';
export type { ReorderResponse } from './models/ReorderResponse';
export type { RepeatSendingMessageSchema } from './models/RepeatSendingMessageSchema';
export type { RepeatSendingTextMessageRequest } from './models/RepeatSendingTextMessageRequest';
export type { RepeatSendingTextMessageResponse } from './models/RepeatSendingTextMessageResponse';
@@ -480,5 +505,6 @@ export { TaskService } from './services/TaskService';
export { TimeTrackingService } from './services/TimeTrackingService';
export { TransactionService } from './services/TransactionService';
export { UserService } from './services/UserService';
export { WmsService } from './services/WmsService';
export { WorkShiftsService } from './services/WorkShiftsService';
export { WorkShiftsPlanningService } from './services/WorkShiftsPlanningService';

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type BasePlaceSchema = {
parentId: (number | null);
placeTypeId: number;
};

View File

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

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type BasePlaceTypeWithCountSchema = {
name: string;
parentId: (number | null);
childCount: number;
};

View File

@@ -7,6 +7,6 @@ export type CardGroupSchema = {
id: number;
name?: (string | null);
lexorank: string;
billRequest?: (GroupBillRequestSchema | null);
billRequests?: Array<GroupBillRequestSchema>;
};

View File

@@ -35,7 +35,7 @@ export type CardSchema = {
clientId: (number | null);
client: (ClientSchema | null);
shippingWarehouse?: (ShippingWarehouseSchema | string | null);
billRequest?: (CardBillRequestSchema | null);
billRequests?: Array<CardBillRequestSchema>;
group?: (CardGroupSchema | null);
manager?: (UserSchema | null);
pallets?: Array<PalletSchema>;

View File

@@ -24,7 +24,7 @@ export type CardSummary = {
attributes: Array<CardAttributeSchema>;
shipmentWarehouseId: (number | null);
shipmentWarehouseName: (string | null);
billRequest?: (CardBillRequestSchema | null);
billRequests: Array<CardBillRequestSchema>;
group?: (CardGroupSchema | null);
};

View File

@@ -3,7 +3,9 @@
/* tslint:disable */
/* eslint-disable */
import type { CardSummary } from './CardSummary';
import type { PaginationInfoSchema } from './PaginationInfoSchema';
export type CardSummaryResponse = {
summaries: Array<CardSummary>;
paginationInfo: PaginationInfoSchema;
};

View File

@@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TgGroupSchema } from './TgGroupSchema';
export type ChatsListItemSchema = {
id: number;
clientId: (number | null);
cardId: (number | null);
tgGroup: (TgGroupSchema | null);
name: string;
};

View File

@@ -15,6 +15,7 @@ export type ClientDetailedSchema = {
comment?: (string | null);
details?: (ClientDetailsSchema | null);
chat?: (ChatSchema | null);
isDeleted?: (boolean | null);
pallets?: Array<ResidualPalletSchema>;
boxes?: Array<ResidualBoxSchema>;
};

View File

@@ -13,5 +13,6 @@ export type ClientSchema = {
comment?: (string | null);
details?: (ClientDetailsSchema | null);
chat?: (ChatSchema | null);
isDeleted?: (boolean | null);
};

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type EditPlaceSchema = {
parentId: (number | null);
placeTypeId: number;
id: number;
number: number;
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetCardSummariesRequest = {
full: boolean;
cardId?: (number | string | null);
cardName?: (string | null);
marketplaceKey?: (string | null);
shippingWarehouseId?: (number | null);
clientId?: (number | null);
projectId?: (number | null);
boardId?: (number | null);
statusId?: (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 { ChatsListItemSchema } from './ChatsListItemSchema';
export type GetChatsListResponse = {
chats: Array<ChatsListItemSchema>;
};

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export type GetTimeTrackingRecordsRequest = {
date: string;
userIds: Array<number>;
dateFrom: string;
dateTo: string;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { BasePlaceTypeWithCountSchema } from './BasePlaceTypeWithCountSchema';
export type PlaceSchema = {
parentId: (number | null);
placeTypeId: number;
id: number;
number: number;
children?: Array<PlaceSchema>;
placeType: BasePlaceTypeWithCountSchema;
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PlaceTypeSchema = {
name: string;
parentId: (number | null);
id: number;
children?: Array<PlaceTypeSchema>;
placesCount: number;
};

View File

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

View File

@@ -56,11 +56,13 @@ import type { CreateCardsFromExcelResponse } from '../models/CreateCardsFromExce
import type { GetAvailableEmployeesToAssignResponse } from '../models/GetAvailableEmployeesToAssignResponse';
import type { GetCardProductsBarcodesPdfRequest } from '../models/GetCardProductsBarcodesPdfRequest';
import type { GetCardProductsBarcodesPdfResponse } from '../models/GetCardProductsBarcodesPdfResponse';
import type { GetCardSummariesRequest } from '../models/GetCardSummariesRequest';
import type { ManageEmployeeRequest } from '../models/ManageEmployeeRequest';
import type { ManageEmployeeResponse } from '../models/ManageEmployeeResponse';
import type { ParseCardsExcelResponse } from '../models/ParseCardsExcelResponse';
import type { ProductsAndServicesGeneralInfoRequest } from '../models/ProductsAndServicesGeneralInfoRequest';
import type { ProductsAndServicesGeneralInfoResponse } from '../models/ProductsAndServicesGeneralInfoResponse';
import type { ReorderResponse } from '../models/ReorderResponse';
import type { UpdateCardClientRequest } from '../models/UpdateCardClientRequest';
import type { UpdateCardClientResponse } from '../models/UpdateCardClientResponse';
import type { UpdateCardManagerRequest } from '../models/UpdateCardManagerRequest';
@@ -155,16 +157,23 @@ export class CardService {
* @throws ApiError
*/
public static getCardSummaries({
full,
requestBody,
page,
itemsPerPage,
}: {
full: (boolean | null),
requestBody: GetCardSummariesRequest,
page?: (number | null),
itemsPerPage?: (number | null),
}): CancelablePromise<CardSummaryResponse> {
return __request(OpenAPI, {
method: 'GET',
method: 'POST',
url: '/card/summaries',
query: {
'full': full,
'page': page,
'items_per_page': itemsPerPage,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
@@ -172,14 +181,14 @@ export class CardService {
}
/**
* Reorder
* @returns CardSummaryResponse Successful Response
* @returns ReorderResponse Successful Response
* @throws ApiError
*/
public static reorderCardSummaries({
requestBody,
}: {
requestBody: CardSummaryReorderRequest,
}): CancelablePromise<CardSummaryResponse> {
}): CancelablePromise<ReorderResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/card/summaries/reorder',

View File

@@ -10,6 +10,7 @@ import type { EditMessageRequest } from '../models/EditMessageRequest';
import type { EditMessageResponse } from '../models/EditMessageResponse';
import type { GetChatRequest } from '../models/GetChatRequest';
import type { GetChatResponse } from '../models/GetChatResponse';
import type { GetChatsListResponse } from '../models/GetChatsListResponse';
import type { GetMessagesRequest } from '../models/GetMessagesRequest';
import type { GetMessagesResponse } from '../models/GetMessagesResponse';
import type { LoadMessagesResponse } from '../models/LoadMessagesResponse';
@@ -150,6 +151,27 @@ export class ChatService {
},
});
}
/**
* Get Client Chats List
* @returns GetChatsListResponse Successful Response
* @throws ApiError
*/
public static getClientChatsList({
clientId,
}: {
clientId: number,
}): CancelablePromise<GetChatsListResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/chat/for-client/{client_id}',
path: {
'client_id': clientId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Create Chat
* @returns CreateChatResponse Successful Response

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

@@ -0,0 +1,211 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CreatePlaceRequest } from '../models/CreatePlaceRequest';
import type { CreatePlaceResponse } from '../models/CreatePlaceResponse';
import type { CreatePlaceTypeRequest } from '../models/CreatePlaceTypeRequest';
import type { CreatePlaceTypeResponse } from '../models/CreatePlaceTypeResponse';
import type { DeletePlaceResponse } from '../models/DeletePlaceResponse';
import type { DeletePlaceTypeResponse } from '../models/DeletePlaceTypeResponse';
import type { EditPlaceRequest } from '../models/EditPlaceRequest';
import type { EditPlaceResponse } from '../models/EditPlaceResponse';
import type { EditPlaceTypeRequest } from '../models/EditPlaceTypeRequest';
import type { EditPlaceTypeResponse } from '../models/EditPlaceTypeResponse';
import type { GetFlatPlaceTypesResponse } from '../models/GetFlatPlaceTypesResponse';
import type { GetPlacesResponse } from '../models/GetPlacesResponse';
import type { GetPlaceTypesResponse } from '../models/GetPlaceTypesResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class WmsService {
/**
* Get Place Types
* @returns GetPlaceTypesResponse Successful Response
* @throws ApiError
*/
public static getPlaceTypes(): CancelablePromise<GetPlaceTypesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/wms/place-type',
});
}
/**
* Create Place Type
* @returns CreatePlaceTypeResponse Successful Response
* @throws ApiError
*/
public static createPlaceType({
requestBody,
}: {
requestBody: CreatePlaceTypeRequest,
}): CancelablePromise<CreatePlaceTypeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/wms/place-type',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Edit Place Type
* @returns EditPlaceTypeResponse Successful Response
* @throws ApiError
*/
public static editPlaceType({
requestBody,
}: {
requestBody: EditPlaceTypeRequest,
}): CancelablePromise<EditPlaceTypeResponse> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/wms/place-type',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Flat Place Types
* @returns GetFlatPlaceTypesResponse Successful Response
* @throws ApiError
*/
public static getFlatPlaceTypes({
parentPlaceTypeId,
}: {
parentPlaceTypeId: number,
}): CancelablePromise<GetFlatPlaceTypesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/wms/place-type/flat/{parent_place_type_id}',
path: {
'parent_place_type_id': parentPlaceTypeId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Place Type
* @returns DeletePlaceTypeResponse Successful Response
* @throws ApiError
*/
public static deletePlaceType({
placeTypeId,
}: {
placeTypeId: number,
}): CancelablePromise<DeletePlaceTypeResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/wms/place-type/{place_type_id}',
path: {
'place_type_id': placeTypeId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Places
* @returns GetPlacesResponse Successful Response
* @throws ApiError
*/
public static getPlaces(): CancelablePromise<GetPlacesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/wms/place',
});
}
/**
* Create Place
* @returns CreatePlaceResponse Successful Response
* @throws ApiError
*/
public static createPlace({
requestBody,
}: {
requestBody: CreatePlaceRequest,
}): CancelablePromise<CreatePlaceResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/wms/place',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Edit Place
* @returns EditPlaceResponse Successful Response
* @throws ApiError
*/
public static editPlace({
requestBody,
}: {
requestBody: EditPlaceRequest,
}): CancelablePromise<EditPlaceResponse> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/wms/place',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Place
* @returns DeletePlaceResponse Successful Response
* @throws ApiError
*/
public static deletePlace({
placeId,
}: {
placeId: number,
}): CancelablePromise<DeletePlaceResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/wms/place/{place_id}',
path: {
'place_id': placeId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Generate Place Qr Code Pdf
* @returns any Successful Response
* @throws ApiError
*/
public static getPlaceQrCodePdf({
placeId,
isShort,
}: {
placeId: number,
isShort: boolean,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/wms/place/pdf/{place_id}/{is_short}',
path: {
'place_id': placeId,
'is_short': isShort,
},
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

@@ -4,13 +4,15 @@ import { UseFormReturnType } from "@mantine/form";
import { DatePickerInput, DateTimePicker } from "@mantine/dates";
import { CardGeneralFormType } from "../../../pages/CardsPage/drawers/CardEditDrawer/tabs/GeneralTab/GeneralTab.tsx";
import { IconInfoCircle } from "@tabler/icons-react";
import { dateWithoutTimezone } from "../../../shared/lib/date.ts";
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 +37,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
<Checkbox
label={label}
{...form.getInputProps(attribute.name, { type: "checkbox" })}
readOnly={readOnly}
/>
);
}
@@ -43,10 +46,20 @@ const CardAttributeField = ({ attribute, form }: Props) => {
<DatePickerInput
label={label}
{...form.getInputProps(attribute.name)}
onChange={(value) => {
if (!value) {
form.getInputProps(attribute.name).onChange(null);
return
}
form.getInputProps(attribute.name).onChange(
dateWithoutTimezone(value),
);
}}
value={getDateValue()}
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY"
readOnly={readOnly}
/>
);
}
@@ -55,10 +68,20 @@ const CardAttributeField = ({ attribute, form }: Props) => {
<DateTimePicker
label={label}
{...form.getInputProps(attribute.name)}
onChange={(value) => {
if (!value) {
form.getInputProps(attribute.name).onChange(null);
return
}
form.getInputProps(attribute.name).onChange(
dateWithoutTimezone(value),
);
}}
value={getDateValue()}
clearable
locale={"ru-RU"}
valueFormat="DD.MM.YYYY HH:mm"
readOnly={readOnly}
/>
);
}
@@ -68,6 +91,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
label={label}
{...form.getInputProps(attribute.name)}
value={form.getInputProps(attribute.name).value ?? ""}
readOnly={readOnly}
/>
);
}
@@ -77,6 +101,7 @@ const CardAttributeField = ({ attribute, form }: Props) => {
allowDecimal={type === "float"}
label={label}
{...form.getInputProps(attribute.name)}
readOnly={readOnly}
/>
);
}

View File

@@ -7,7 +7,12 @@ import MessageInput from "./components/MessageInput/MessageInput.tsx";
import { Virtuoso } from "react-virtuoso";
import { Stack } from "@mantine/core";
const Chat = () => {
type Props = {
height?: number | string;
padding?: number | string;
}
const Chat = ({ height = "96vh", padding = 0 }: Props) => {
const {
messages,
lastMessage,
@@ -45,7 +50,7 @@ const Chat = () => {
);
}
return (
<Stack mb={"xs"} mr={"xs"}>
<Stack mt={"xs"} mr={"xs"} ml={padding}>
{dateComponent}
<Message
key={`${sessionData.id}${index}`}
@@ -59,14 +64,14 @@ const Chat = () => {
if (messages.length === 0) {
return (
<Stack h={"96vh"} justify={"flex-end"}>
<MessageInput />
<Stack h={height} justify={"flex-end"}>
<MessageInput inputMargin={padding} />
</Stack>
);
}
return (
<Stack h={"96vh"}>
<Stack h={height}>
<Virtuoso
data={messages}
followOutput={onFollowOutputHandler}
@@ -78,7 +83,7 @@ const Chat = () => {
increaseViewportBy={200}
alignToBottom
/>
<MessageInput />
<MessageInput inputMargin={padding} />
</Stack>
);
};

View File

@@ -0,0 +1,12 @@
.chats-list-item {
cursor: pointer;
}
.chats-list-item-selected {
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
@mixin light {
background-color: var(--mantine-color-gray-3);
}
}

View File

@@ -0,0 +1,50 @@
import { Center, List, Text } from "@mantine/core";
import { ChatSchema, ChatsListItemSchema } from "../../../../client";
import { IconBriefcase, IconGrid3x3 } from "@tabler/icons-react";
import classNames from "classnames";
import styles from "./ChatsList.module.css";
type Props = {
chats: ChatsListItemSchema[];
onChatSelect: (chat: ChatSchema) => void;
selectedChat: ChatSchema | null;
}
const ChatsList = ({ chats, onChatSelect, selectedChat }: Props) => {
const clientChat = chats.find(chat => chat.clientId);
chats = chats.filter(chat => !chat.clientId);
const chatItem = (chat: ChatsListItemSchema, isGeneral: boolean) => {
return (
<List.Item
p={"sm"}
onClick={() => onChatSelect(chat)}
key={chat.id}
icon={(
<Center>
{isGeneral ? (
<IconGrid3x3 />
) : (
<IconBriefcase />
)}
</Center>
)}
className={classNames(
styles["chats-list-item"],
selectedChat?.id === chat.id && styles["chats-list-item-selected"],
)}
>
<Text>{chat.name}</Text>
</List.Item>
);
};
return (
<List center>
{clientChat && chatItem(clientChat, true)}
{chats.map(chat => chatItem(chat, false))}
</List>
);
};
export default ChatsList;

View File

@@ -6,7 +6,11 @@ import SelectedFile from "../SelectedFile/SelectedFile.tsx";
import React, { useRef } from "react";
const MessageInput = () => {
type Props = {
inputMargin?: number | string;
}
const MessageInput = ({ inputMargin }: Props) => {
const formRef = useRef<HTMLFormElement>(null);
const {
@@ -26,7 +30,7 @@ const MessageInput = () => {
}
};
const getFiles = files.map(file => (
const filesList = files.map(file => (
<SelectedFile key={file.name} file={file} />
));
@@ -34,8 +38,12 @@ const MessageInput = () => {
<form ref={formRef} onSubmit={form.onSubmit(values => submitMessage(values))}>
<Stack gap={"xs"}>
<Divider />
{getFiles}
<Group wrap={"nowrap"} align={"flex-end"}>
{filesList.length > 0 && (
<Stack mx={inputMargin}>
{filesList}
</Stack>
)}
<Group wrap={"nowrap"} align={"flex-end"} mx={inputMargin}>
{chat?.tgGroup?.tgInviteLink && (
<ActionIconCopy
onCopiedLabel={"Ссылка на чат скопирована в буфер обмена"}

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

@@ -56,7 +56,11 @@ const CardAttributesInSummaryItem = ({ cardSummary }: Props) => {
.map(cardAttr => {
const isHighlight = isHighlightNeeded(cardAttr);
return (
<Text c={isHighlight ? "red" : "gray.6"} size={"sm"}>
<Text
key={cardAttr.attribute.id}
c={isHighlight ? "red" : "gray.6"}
size={"sm"}
>
{cardAttr.attribute.label}: {getAttrValueValue(cardAttr)}
</Text>
);

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

@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import { CardService, CardSummary } from "../../../../client";
import styles from "./CardSummaryItem.module.css";
@@ -14,6 +14,9 @@ import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
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;
@@ -25,6 +28,8 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
const { selectedProject } = useProjectsContext();
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);
@@ -34,22 +39,23 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
setSelectedCard(card);
});
};
const isPaid = () => {
return cardSummary.billRequest?.paid || cardSummary.group?.billRequest?.paid;
};
const isLockedInsideGroup = () => {
return cardSummary.group && !cardSummary.group.billRequest;
const canBeRemovedFromGroup = () => {
return !!(cardSummary.group && (!cardSummary.group.billRequests || cardSummary.group.billRequests?.length === 0));
};
useEffect(() => {
setIsPaid(isDealPaid(cardSummary));
}, []);
return (
<div
onContextMenu={showContextMenu([
...isLockedInsideGroup() ? [{
onContextMenu={!authState.isDealsViewer ? showContextMenu([
...!canBeRemovedFromGroup() ? [] : [{
key: "removeFromGroup",
onClick: () => onDeleteFromGroup(cardSummary),
title: "Убрать из группы",
icon: <IconLayoutGridRemove />,
}] : [],
}],
{
key: "complete",
onClick: () => onComplete(cardSummary),
@@ -62,7 +68,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
title: "Удалить",
icon: <IconTrash />,
},
])}
]) : undefined}
onClick={() => onCardSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
@@ -105,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"}>
@@ -147,7 +153,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
</Popover>
)}
</CopyButton>
{isPaid() && (
{isPaid && (
<Tooltip label={"Оплачен"}>
<ThemeIcon variant={"transparent"}>
<IconCheck />

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

@@ -1,27 +1,17 @@
import { modals } from "@mantine/modals";
import { Flex } from "@mantine/core";
import { DropResult } from "@hello-pangea/dnd";
import { useEffect, useState } from "react";
import { CardGroupService, CardService, CardSummary, CardSummaryReorderRequest } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { dateWithoutTimezone } from "../../../../../shared/lib/date.ts";
type Props = {
summariesRaw: CardSummary[];
summaries: CardSummary[];
refetchSummaries: () => void;
}
const useCardsDnd = ({
summariesRaw,
refetchSummaries,
}: Props) => {
const [summaries, setSummaries] = useState(summariesRaw);
useEffect(() => {
setSummaries(summariesRaw);
}, [summariesRaw]);
const useCardsDnd = ({ summaries, refetchSummaries }: Props) => {
const recalculate = async (cardId: number) => {
return CardService.recalculateCardPrice({
requestBody: {
@@ -103,7 +93,6 @@ const useCardsDnd = ({
notifications.error({ message: response.message });
return;
}
await refetchSummaries();
await recalculate(sourceId);
await refetchSummaries();
});
@@ -120,7 +109,6 @@ const useCardsDnd = ({
notifications.error({ message: response.message });
return;
}
await refetchSummaries();
await recalculate(sourceId);
await refetchSummaries();
});
@@ -185,8 +173,7 @@ const useCardsDnd = ({
if (statusId == summary.status.id) {
CardService.reorderCardSummaries({
requestBody: request as CardSummaryReorderRequest,
}).then(async response => {
setSummaries(response.summaries);
}).then(async () => {
await refetchSummaries();
});
return;
@@ -200,14 +187,12 @@ const useCardsDnd = ({
comment: "",
deadline: dateWithoutTimezone(new Date()),
},
}).then(async response => {
setSummaries(response.summaries);
}).then(async () => {
await refetchSummaries();
});
};
return {
summaries,
onCardDragEnd,
};
};

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

@@ -10,11 +10,14 @@ import getRenderOptions from "./utils/getRenderOptions.tsx";
type RestProps = {
clientId: number;
};
const MAX_PRODUCTS = 200;
type Props = Omit<ObjectSelectProps<ProductSchema>, "data"> & RestProps;
const ProductSelect: FC<Props> = (props: Props) => {
const [searchValue, setSearchValue] = useState("");
const [debounced] = useDebouncedValue(searchValue, 500);
const [searchValueServer, setSearchValueServer] = useState("");
const [debounced] = useDebouncedValue(searchValueServer, 500);
const { products, isLoading } = useProductsList({
clientId: props.clientId,
searchInput: debounced,
@@ -22,21 +25,35 @@ const ProductSelect: FC<Props> = (props: Props) => {
itemsPerPage: MAX_PRODUCTS,
});
const restProps = omit(props, ["clientId"]);
const optionsFilter: OptionsFilter = ({ options }) => options;
const filterProducts = (searchFilter: string): ProductSchema[] => {
searchFilter = searchFilter.toLowerCase().trim();
if (!searchFilter) return products;
const filteredByName = products.filter((v) => v.name.toLowerCase().includes(searchFilter));
const filteredByBarcodes = products.filter((v) => v.barcodes?.some(barcode => barcode.toLowerCase().includes(searchFilter)));
const filteredByArticle = products.filter((v) => v.article == searchFilter);
const uniqueProducts = new Set([...filteredByName, ...filteredByBarcodes, ...filteredByArticle]);
return Array.from(uniqueProducts).sort((a, b) => a.id - b.id);
};
const optionsFilter: OptionsFilter = ({ search }) => {
return filterProducts(search).map(product => ({ label: product.name, value: product.id.toString() }));
};
const setSearchValueImpl = (value: string) => {
const names = products.map(product => product.name);
if (names.includes(value)) return;
setSearchValue(value);
const filtered = filterProducts(value);
if (filtered && filtered.length > 0) {
return;
}
setSearchValueServer(value);
};
return (
<ObjectSelect
rightSection={
isLoading || searchValue !== debounced ? (
isLoading? (
<Loader size={"sm"} />
) : null
}
searchValue={searchValue}
onSearchChange={setSearchValueImpl}
renderOption={getRenderOptions(products)}
searchable

View File

@@ -1,44 +1,23 @@
import { FC, ReactNode } from "react";
import { Select } from "@mantine/core";
import { FC } from "react";
import { ClientSchema } from "../../../client";
import useClientsList from "../../../pages/ClientsPage/hooks/useClientsList.tsx";
import ObjectSelect, { ObjectSelectProps } from "../../ObjectSelect/ObjectSelect.tsx";
type Props = Omit<
ObjectSelectProps<ClientSchema>,
"data" | "getLabelFn" | "getValueFn"
>;
const ClientSelect: FC<Props> = (props: Props) => {
const { clients } = useClientsList({ all: true });
type Props = {
value?: ClientSchema;
onChange: (client: ClientSchema) => void;
withLabel?: boolean;
error?: string;
inputContainer?: (children: ReactNode) => ReactNode;
disabled?: boolean;
};
const ClientSelect: FC<Props> = ({ value, onChange, error, inputContainer, withLabel = false, disabled = false }) => {
const { clients } = useClientsList();
const options = clients.map(client => ({
label: client.name,
value: client.id.toString(),
}));
return (
<Select
<ObjectSelect
{...props}
searchable
placeholder={"Выберите клиента"}
value={
value &&
options.find(client => client.value == value.id.toString())
?.value
}
onChange={event => {
if (!event) return;
const client = clients.find(
client => client.id == parseInt(event)
);
if (!client) return;
onChange(client);
}}
data={options}
label={withLabel && "Клиент"}
error={error}
inputContainer={inputContainer}
disabled={disabled}
data={clients.filter(cl => !cl.isDeleted)}
/>
);
};

View File

@@ -8,7 +8,7 @@ import useClientsList from "../../../pages/ClientsPage/hooks/useClientsList.tsx"
type Props = Omit<ObjectSelectProps<ClientSchema>, "data">;
const ClientSelectNew: FC<Props> = props => {
const { clients } = useClientsList();
const { clients } = useClientsList({ all: false });
return (
<ObjectSelect
searchable

View File

@@ -1,9 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { jwtDecode, JwtPayload as JwtPayloadBase } from "jwt-decode";
import { OpenAPI } from "../client";
interface AuthState {
isAuthorized: boolean;
accessToken: string;
isDealEditor: boolean;
isDealsViewer: boolean;
isGuest: boolean;
role: string;
}
@@ -16,6 +19,8 @@ const initialState = (): AuthState => {
return {
accessToken: "",
isAuthorized: false,
isDealEditor: false,
isDealsViewer: false,
isGuest: false,
role: "user",
};
@@ -34,10 +39,13 @@ const authSlice = createSlice({
const { sub, role } = jwtDecode<JwtPayload>(
action.payload.accessToken,
);
OpenAPI.TOKEN = action.payload.accessToken;
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

@@ -1,9 +1,12 @@
import { useEffect, useState } from "react";
import { type FullProjectSchema, ProjectService } from "../client";
import { useSelector } from "react-redux";
import { RootState } from "../redux/store.ts";
const useProjects = () => {
const [projects, setProjects] = useState<FullProjectSchema[]>([]);
const authState = useSelector((state: RootState) => state.auth);
const refetchProjects = () => {
ProjectService.getProjects()
@@ -14,8 +17,10 @@ const useProjects = () => {
};
useEffect(() => {
refetchProjects();
}, []);
if (authState.isAuthorized) {
refetchProjects();
}
}, [authState.isAuthorized]);
return {
projects,

View File

@@ -42,6 +42,9 @@ import AttributeModal from "../pages/AdminPage/tabs/Attributes/modals/AttributeM
import CreateProjectModal
from "../pages/CardsPage/drawers/ProjectEditDrawer/tabs/General/modals/CreateProjectModal.tsx";
import CardTagModal from "../pages/CardsPage/drawers/ProjectEditDrawer/tabs/Tags/modals/CardTagModal.tsx";
import PlaceTypeModal from "../pages/AdminPage/tabs/WarehouseManagement/placeType/modals/PlaceTypeModal.tsx";
import PlaceModal from "../pages/AdminPage/tabs/WarehouseManagement/place/modals/PlaceModal.tsx";
import SelectPlaceQrType from "../pages/AdminPage/tabs/WarehouseManagement/place/modals/SelectPlaceQrType.tsx";
export const modals = {
enterDeadline: EnterDeadlineModal,
@@ -82,4 +85,7 @@ export const modals = {
attributeModal: AttributeModal,
createProjectModal: CreateProjectModal,
cardTagModal: CardTagModal,
placeTypeModal: PlaceTypeModal,
placeModal: PlaceModal,
selectPlaceQrType: SelectPlaceQrType,
};

View File

@@ -22,12 +22,14 @@ const ClientTab = () => {
initialValues,
validate: {
details: {
phoneNumber: value =>
!phone(value || "", {
phoneNumber: value => {
if (!value || value === "+7 ") return false;
return !phone(value || "", {
country: "",
strictDetection: false,
validateMobilePrefix: false,
}).isValid && "Неверно указан номер телефона",
}).isValid && "Неверно указан номер телефона";
},
inn: (inn: string | undefined | null) => inn && !isValidInn(inn) ? "Некорректный ИНН" : null,
},
},
@@ -40,9 +42,10 @@ const ClientTab = () => {
form.setValues(data);
}, [card]);
const isEditorDisabled = () => client?.id !== card?.client?.id;
const isEditorDisabled = () => client?.id !== card?.client?.id || client?.isDeleted === true;
const handleSubmitClientInfo = (values: ClientSchema) => {
if (values.details?.phoneNumber === "+7 ") values.details!.phoneNumber = "";
ClientService.updateClient({
requestBody: {
data: values,
@@ -98,6 +101,7 @@ const ClientTab = () => {
mask="+7 000 000-00-00"
placeholder={"Введите номер телефона"}
{...form.getInputProps("details.phoneNumber")}
disabled={isEditorDisabled()}
/>
</Input.Wrapper>
<TextInput
@@ -145,7 +149,6 @@ const ClientTab = () => {
<ClientSelect
value={client}
onChange={setClient}
withLabel
disabled={!isEqual(initialValues, form.values) || !!card?.chat}
/>
{!card?.chat && (
@@ -164,4 +167,4 @@ const ClientTab = () => {
</Stack>
);
};
export default ClientTab;
export default ClientTab;

View File

@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import styles from "./ProductAndServiceTab.module.css";
import ProductView from "./components/ProductView/ProductView.tsx";
import { Button, Checkbox, Divider, Flex, Group, rem, ScrollArea, Stack, Text, Title } from "@mantine/core";
@@ -20,10 +20,22 @@ import GeneralDataForm from "./components/GeneralDataForm/GeneralDataForm.tsx";
import PrintDealBarcodesButton from "./components/PrintDealBarcodesButton/PrintDealBarcodesButton.tsx";
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 isLocked = Boolean(cardState.card?.billRequest || cardState.card?.group?.billRequest);
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));
}, []);
const onAddProductClick = () => {
if (!cardProductsState.onCreate || !cardState.card || !cardState.card.clientId) return;
const productIds = cardState.card.products.map(
@@ -121,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 = () => {
@@ -222,7 +236,7 @@ const ProductAndServiceTab: FC = () => {
<div
className={classNames(
styles["container"],
cardState.card?.billRequest && styles["container-disabled"],
cardState.card?.billRequests?.length && styles["container-disabled"],
)}>
<div className={styles["products-list"]}>
<ScrollArea offsetScrollbars>
@@ -249,18 +263,18 @@ const ProductAndServiceTab: FC = () => {
mr={"xs"}
>
<Group wrap={"nowrap"}>
<PrintDealBarcodesButton card={cardState.card} />
{!isDealsViewer && <PrintDealBarcodesButton card={cardState.card} />}
<Checkbox
label={"Оплачен"}
checked={cardState.card.billRequest?.paid || cardState.card.group?.billRequest?.paid || false}
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
@@ -271,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

@@ -12,6 +12,7 @@ import { RootState } from "../../../../../../redux/store.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
import LockCheckbox from "../../../../../../components/LockCheckbox/LockCheckbox.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
import { isDealLocked } from "../../../../../../pages/CardsPage/utils/isDealPaid.ts";
type RestProps = {
onKitAdd?: (kit: GetServiceKitSchema) => void;
@@ -31,7 +32,7 @@ const CardServicesTable: FC<Props> = ({
const authState = useSelector((state: RootState) => state.auth);
const { cardState } = useCardProductAndServiceTabState();
const isLocked = Boolean(cardState.card?.billRequest);
const isLocked = isDealLocked(cardState.card);
const [currentService, setCurrentService] = useState<
CardServiceSchema | undefined
@@ -137,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
@@ -166,9 +169,8 @@ const CardServicesTable: FC<Props> = ({
isNumber(event) &&
onQuantityChange(service, event)
}
value={service.quantity}
readOnly={authState.isDealsViewer}
/>
<NumberInput
flex={1}
@@ -179,6 +181,7 @@ const CardServicesTable: FC<Props> = ({
suffix={"₽"}
value={service.price}
disabled={authState.isGuest || isLocked || service.isFixedPrice}
readOnly={authState.isDealsViewer}
rightSectionProps={{
style: {
display: "flex",
@@ -187,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>
@@ -211,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

@@ -3,19 +3,40 @@ import ButtonCopy from "../../../../../../components/ButtonCopy/ButtonCopy.tsx";
import { ButtonCopyControlled } from "../../../../../../components/ButtonCopyControlled/ButtonCopyControlled.tsx";
import { getCurrentDateTimeForFilename } from "../../../../../../shared/lib/date.ts";
import FileSaver from "file-saver";
import { Button, Popover, Stack } from "@mantine/core";
type Props = {
card: CardSchema;
}
const PaymentLinkButton = ({ card }: Props) => {
const billRequestPdfUrl = card?.billRequest?.pdfUrl || card?.group?.billRequest?.pdfUrl;
if ((!card.billRequests || card.billRequests.length === 0) && (!card?.group?.billRequests || card?.group?.billRequests.length === 0)) {
return (
<ButtonCopyControlled
onCopyClick={() => {
const date =
getCurrentDateTimeForFilename();
FileSaver.saveAs(
`${import.meta.env.VITE_API_URL}/card/billing-document/${card.id}`,
`bill_${card.id}_${date}.pdf`,
);
}}
copied={false}
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
>
Ссылка на оплату (PDF)
</ButtonCopyControlled>
);
}
if (billRequestPdfUrl) {
const requests = (card?.group ? card?.group?.billRequests : card.billRequests) ?? [];
const urls = requests.map(request => request.pdfUrl).filter(url => url !== null);
if (urls.length === 1) {
return (
<ButtonCopy
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={billRequestPdfUrl}
value={urls[0]}
>
Ссылка на оплату
</ButtonCopy>
@@ -23,21 +44,25 @@ const PaymentLinkButton = ({ card }: Props) => {
}
return (
<ButtonCopyControlled
onCopyClick={() => {
const date =
getCurrentDateTimeForFilename();
FileSaver.saveAs(
`${import.meta.env.VITE_API_URL}/card/billing-document/${card.id}`,
`bill_${card.id}_${date}.pdf`,
);
}}
copied={false}
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
>
Ссылка на оплату (PDF)
</ButtonCopyControlled>
<Popover width={380} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button variant={"default"}>Ссылки на оплату</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={"md"}>
{urls.map((url, i) => (
<ButtonCopy
key={i}
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={url}
>
{`Ссылка на оплату (часть ${String(i + 1)})`}
</ButtonCopy>
))}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
};
export default PaymentLinkButton;

View File

@@ -11,6 +11,7 @@ import SimpleUsersTable from "../../../../../../pages/CardsPage/components/Simpl
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
import { isDealLocked } from "../../../../../../pages/CardsPage/utils/isDealPaid.ts";
type RestProps = {
quantity: number;
@@ -20,16 +21,16 @@ type RestProps = {
type Props = CRUDTableProps<CardProductServiceSchema> & RestProps;
const ProductServicesTable: FC<Props> = ({
items,
quantity,
onCreate,
onDelete,
onChange,
onCopyServices,
onKitAdd,
}) => {
items,
quantity,
onCreate,
onDelete,
onChange,
onCopyServices,
onKitAdd,
}) => {
const { cardState } = useCardProductAndServiceTabState();
const isLocked = Boolean(cardState.card?.billRequest);
const isLocked = isDealLocked(cardState.card);
const authState = useSelector((state: RootState) => state.auth);
const columns = useProductServicesTableColumns({ data: items, quantity });
@@ -78,7 +79,7 @@ const ProductServicesTable: FC<Props> = ({
const getCurrentEmployees = (): UserSchema[] => {
if (!currentService) return [];
const item = items.find(
i => i.service.id === currentService.service.id
i => i.service.id === currentService.service.id,
);
if (!item) return [];
return item.employees;
@@ -102,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"}
@@ -158,7 +159,7 @@ const ProductServicesTable: FC<Props> = ({
<ActionIcon
onClick={() =>
onEmployeeClick(
row.original
row.original,
)
}
variant={"default"}>

View File

@@ -14,6 +14,9 @@ import { modals } from "@mantine/modals";
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;
@@ -47,12 +50,14 @@ const ProductView: FC<Props> = ({
if (!onChange) return;
onChange(item);
}, 200);
const isLocked = Boolean(cardState.card?.billRequest);
const isLocked = isDealLocked(cardState.card);
const onDeleteClick = () => {
if (!onDelete) return;
onDelete(product);
};
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
const onServiceDelete = (item: CardProductServiceSchema) => {
if (!onChange) return;
onChange({
@@ -161,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"]}>
@@ -194,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

@@ -9,6 +9,7 @@ import {
IconQrcode, IconSubtask,
IconTopologyStar3,
IconUser,
IconBuildingWarehouse,
} from "@tabler/icons-react";
import UsersTab from "./tabs/Users/UsersTab.tsx";
import { motion } from "framer-motion";
@@ -22,6 +23,7 @@ import OrganizationalStructureTab from "./tabs/OrganizationalStructureTab/Organi
import { ReactNode } from "react";
import WorkShiftsPlanning from "./tabs/WorkShiftsPlanning/WorkShiftsPlanning.tsx";
import Attributes from "./tabs/Attributes/Attributes.tsx";
import WarehouseManagementTab from "./tabs/WarehouseManagement/WarehouseManagementTab.tsx";
const AdminPage = () => {
const userRole = useSelector((state: RootState) => state.auth.role);
@@ -96,6 +98,11 @@ const AdminPage = () => {
Атрибуты карточек
</Tabs.Tab>
)}
<Tabs.Tab
value={"warehouseManagement"}
leftSection={<IconBuildingWarehouse />}>
Управление складом
</Tabs.Tab>
</Tabs.List>
{getTabPanel("users", <UsersTab />)}
{getTabPanel("rolesAndPositions", <OrganizationalStructureTab />)}
@@ -105,6 +112,7 @@ const AdminPage = () => {
{getTabPanel("workShifts", <WorkShiftsTab />)}
{getTabPanel("transactions", <TransactionsTab />)}
{getTabPanel("attributes", <Attributes />)}
{getTabPanel("warehouseManagement", <WarehouseManagementTab />)}
</Tabs>
</PageBlock>
</div>

View File

@@ -0,0 +1,28 @@
import { WarehouseManagementTabContextProvider } from "./placeType/contexts/WarehouseManagementTabContext.tsx";
import { WmsPage, WmsSegmentedControl } from "./placeType/components/WmsSegmentedControl.tsx";
import { useState } from "react";
import PlaceTypesEditor from "./placeType/components/PlaceTypesEditor.tsx";
import PlacesEditor from "./place/components/PlacesEditor.tsx";
const WarehouseManagementTab = () => {
const [page, setPage] = useState(WmsPage.PLACE);
return (
<WarehouseManagementTabContextProvider>
<WmsSegmentedControl
w={"100%"}
value={page.toString()}
onChange={event => {
setPage(parseInt(event));
}}
/>
{page === WmsPage.PLACE_TYPE ? (
<PlaceTypesEditor />
) : (
<PlacesEditor />
)}
</WarehouseManagementTabContextProvider>
);
};
export default WarehouseManagementTab;

View File

@@ -0,0 +1,57 @@
import { DataTable } from "mantine-datatable";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { useState } from "react";
import { PlaceSchema } from "../../../../../../client";
import PlaceActions from "./PlaceActions.tsx";
type Props = {
place: PlaceSchema;
}
const Place = ({ place }: Props) => {
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<DataTable
noHeader
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, number, placeType }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{placeType.name} {number}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (place) => (
<PlaceActions place={place} />
),
},
]}
records={place.children?.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<Place place={record} />
),
}}
/>
);
};
export default Place;

View File

@@ -0,0 +1,69 @@
import { PlaceSchema } from "../../../../../../client";
import { IconEdit, IconPlaylistAdd, IconQrcode, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import React, { ReactNode } from "react";
import { useWarehouseManagementTabContext } from "../../placeType/contexts/WarehouseManagementTabContext.tsx";
type Props = {
place: PlaceSchema;
}
const PlaceActions = ({ place }: Props) => {
const { placeCrud, onCreatePlace, onEditPlace, generateQrCode } = useWarehouseManagementTabContext();
const getAction = (
label: string,
func: () => void,
icon: ReactNode,
disabled: boolean = false,
) => {
return (
<Tooltip label={label} key={label}>
<ActionIcon
variant={"default"}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
func();
}}
disabled={disabled}
>
{icon}
</ActionIcon>
</Tooltip>
);
};
const actions = [
getAction(
"Распечатать QR-код",
() => generateQrCode(place),
<IconQrcode />,
),
getAction(
"Добавить",
() => onCreatePlace(place),
<IconPlaylistAdd />,
place.placeType.childCount === 0,
),
getAction(
"Редактировать",
() => onEditPlace(place),
<IconEdit />,
),
getAction(
"Удалить",
() => placeCrud.onDelete && placeCrud.onDelete(place),
<IconTrash />,
place.children?.length !== 0,
),
];
return (
<Flex gap={"md"} mx={"md"} direction={"row"}>
{...actions}
</Flex>
);
};
export default PlaceActions;

View File

@@ -0,0 +1,23 @@
import { FC } from "react";
import { FlatPlaceTypeSchema, PlaceTypeSchema } from "../../../../../../client";
import ObjectSelect, { ObjectSelectProps } from "../../../../../../components/ObjectSelect/ObjectSelect.tsx";
type Props = Omit<
ObjectSelectProps<FlatPlaceTypeSchema | PlaceTypeSchema | null>,
"getValueFn" | "getLabelFn"
>;
const PlaceTypeSelect: FC<Props> = (props) => {
return (
<ObjectSelect
getLabelFn={(placeType: FlatPlaceTypeSchema) => placeType.name}
getValueFn={(placeType: FlatPlaceTypeSchema) => placeType.id.toString()}
clearable
searchable
{...props}
onClear={() => props.onChange(null)}
/>
);
};
export default PlaceTypeSelect;

View File

@@ -0,0 +1,67 @@
import { Button, Group, Stack } from "@mantine/core";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { DataTable } from "mantine-datatable";
import { useState } from "react";
import { useWarehouseManagementTabContext } from "../../placeType/contexts/WarehouseManagementTabContext.tsx";
import PlaceActions from "./PlaceActions.tsx";
import Place from "./Place.tsx";
const PlacesEditor = () => {
const { places, onCreatePlace } = useWarehouseManagementTabContext();
const [placeIds, setPlaceIds] = useState<number[]>([]);
return (
<Stack w={"100%"} mt={"md"}>
<Group>
<Button
variant={"default"}
onClick={() => onCreatePlace()}
>
Добавить
</Button>
</Group>
<DataTable
noHeader
withTableBorder
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, number, placeType }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{placeType.name} {number}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (place) => (
<PlaceActions place={place} />
),
},
]}
records={places.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeIds, onRecordIdsChange: setPlaceIds },
content: ({ record }) => (
<Place place={record} />
),
}}
/>
</Stack>
);
};
export default PlacesEditor;

View File

@@ -0,0 +1,56 @@
import { useCRUD } from "../../../../../../hooks/useCRUD.tsx";
import { BasePlaceSchema, EditPlaceSchema, PlaceSchema, WmsService } from "../../../../../../client";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
export type PlaceCrud = {
onCreate: (element: BasePlaceSchema) => void,
onDelete: (element: PlaceSchema) => void,
onChange: (element: EditPlaceSchema) => void
}
type Props = {
fetchPlaces: () => void;
}
const usePlacesCrud = ({ fetchPlaces }: Props): PlaceCrud => {
return useCRUD<EditPlaceSchema, BasePlaceSchema>({
onChange: (place: EditPlaceSchema) => {
WmsService.editPlace({
requestBody: { place },
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onDelete: (place: EditPlaceSchema) => {
WmsService.deletePlace({
placeId: place.id,
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onCreate: (place: BasePlaceSchema) => {
WmsService.createPlace({
requestBody: {
place,
},
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
});
};
export default usePlacesCrud;

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const usePlacesList = () =>
ObjectList({
queryFn: WmsService.getPlaces,
getObjectsFn: response => response.places,
queryKey: "getAllPlaces",
});
export default usePlacesList;

View File

@@ -0,0 +1,91 @@
import { ContextModalProps } from "@mantine/modals";
import { EditPlaceSchema, FlatPlaceTypeSchema, PlaceSchema, PlaceTypeSchema } from "../../../../../../client";
import { Button, NumberInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { PlaceCrud } from "../hooks/usePlacesCrud.tsx";
import PlaceTypeSelect from "../components/PlaceTypeSelect.tsx";
type Props = {
placeCrud: PlaceCrud;
parent?: PlaceSchema;
placeTypes?: FlatPlaceTypeSchema[];
element?: EditPlaceSchema;
}
type PlaceModalForm = {
placeType: PlaceTypeSchema | null;
placeNumber: number | null;
}
const PlaceModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "element" in innerProps;
const { parent, placeCrud, placeTypes } = innerProps;
const closeModal = () => {
context.closeContextModal(id);
};
const initialValues: PlaceModalForm = {
placeType: null,
placeNumber: innerProps.element?.number ?? null,
};
const form = useForm<PlaceModalForm>({
initialValues,
validate: {
placeType: placeType => !isEditing && !placeType && "Необходимо указать тип",
placeNumber: placeNumber => isEditing && !placeNumber && "Необходимо ввести номер",
},
});
const onSubmit = (values: PlaceModalForm) => {
if (isEditing) {
const place = innerProps.element!;
placeCrud.onChange({
id: place.id,
placeTypeId: place.placeTypeId,
number: values.placeNumber!,
parentId: place.parentId,
});
} else {
if (!values.placeType) return;
placeCrud.onCreate({
placeTypeId: values.placeType.id,
parentId: parent?.id || null,
});
}
closeModal();
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
{!isEditing && (
<PlaceTypeSelect
label={"Тип места на складе"}
{...form.getInputProps("placeType")}
data={placeTypes ?? []}
defaultValue={(form.getValues().placeType ?? "") as FlatPlaceTypeSchema & string}
/>
)}
{isEditing && (
<NumberInput
label={"Номер места на складе"}
{...form.getInputProps("placeNumber")}
defaultValue={form.getValues().placeNumber ?? ""}
/>
)}
<Button variant={"default"} type={"submit"}>
Сохранить
</Button>
</Stack>
</form>
);
};
export default PlaceModal;

View File

@@ -0,0 +1,46 @@
import { ContextModalProps } from "@mantine/modals";
import { PlaceSchema } from "../../../../../../client";
import { Stack } from "@mantine/core";
import InlineButton from "../../../../../../components/InlineButton/InlineButton.tsx";
import { IconBoxAlignBottom, IconQrcode } from "@tabler/icons-react";
type Props = {
place: PlaceSchema;
}
const SelectPlaceQrType = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const { place } = innerProps;
const closeModal = () => {
context.closeContextModal(id);
};
const generateQrCode = (isShort: boolean) => {
const pdfWindow = window.open(
`${import.meta.env.VITE_API_URL}/wms/place/pdf/${place.id}/${isShort}`,
);
if (!pdfWindow) return;
pdfWindow.print();
closeModal();
};
return (
<Stack>
<InlineButton onClick={() => generateQrCode(false)}>
<IconQrcode />
Целая наклейка
</InlineButton>
<InlineButton onClick={() => generateQrCode(true)}>
<IconBoxAlignBottom />
Половина наклейки
</InlineButton>
</Stack>
);
};
export default SelectPlaceQrType;

View File

@@ -0,0 +1,57 @@
import { PlaceTypeSchema } from "../../../../../../client";
import { DataTable } from "mantine-datatable";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { useState } from "react";
import PlaceTypeActions from "./PlaceTypeActions.tsx";
type Props = {
placeType: PlaceTypeSchema;
}
const PlaceType = ({ placeType }: Props) => {
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<DataTable
noHeader
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, name }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{name}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (placeType) => (
<PlaceTypeActions placeType={placeType} />
),
},
]}
records={placeType.children?.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<PlaceType placeType={record} />
),
}}
/>
);
};
export default PlaceType;

View File

@@ -0,0 +1,82 @@
import { PlaceTypeSchema } from "../../../../../../client";
import { IconEdit, IconPlaylistAdd, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import React, { ReactNode } from "react";
import { useWarehouseManagementTabContext } from "../contexts/WarehouseManagementTabContext.tsx";
import { modals } from "@mantine/modals";
type Props = {
placeType: PlaceTypeSchema;
}
const PlaceTypeActions = ({ placeType }: Props) => {
const { placeTypeCrud } = useWarehouseManagementTabContext();
const getAction = (
label: string,
func: () => void,
icon: ReactNode,
disabled: boolean = false,
) => {
return (
<Tooltip label={label} key={label}>
<ActionIcon
disabled={disabled}
variant={"default"}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
func();
}}
>
{icon}
</ActionIcon>
</Tooltip>
);
};
const actions = [
getAction(
"Добавить",
() => {
modals.openContextModal({
modal: "placeTypeModal",
withCloseButton: false,
innerProps: {
placeTypeCrud,
parent: placeType,
},
});
},
<IconPlaylistAdd />,
),
getAction(
"Редактировать",
() => {
modals.openContextModal({
modal: "placeTypeModal",
withCloseButton: false,
innerProps: {
placeTypeCrud,
element: placeType,
},
});
},
<IconEdit />,
),
getAction(
"Удалить",
() => placeTypeCrud.onDelete && placeTypeCrud.onDelete(placeType),
<IconTrash />,
placeType.placesCount !== 0 || placeType.children?.length !== 0,
),
];
return (
<Flex gap={"md"} mx={"md"} direction={"row"}>
{...actions}
</Flex>
);
};
export default PlaceTypeActions;

View File

@@ -0,0 +1,76 @@
import { Button, Group, Stack } from "@mantine/core";
import { useWarehouseManagementTabContext } from "../contexts/WarehouseManagementTabContext.tsx";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { DataTable } from "mantine-datatable";
import { useState } from "react";
import PlaceType from "./PlaceType.tsx";
import PlaceTypeActions from "./PlaceTypeActions.tsx";
import { modals } from "@mantine/modals";
const PlaceTypesEditor = () => {
const { placeTypes, placeTypeCrud } = useWarehouseManagementTabContext();
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<Stack w={"100%"} mt={"md"}>
<Group>
<Button
variant={"default"}
onClick={() => {
modals.openContextModal({
modal: "placeTypeModal",
withCloseButton: false,
innerProps: {
placeTypeCrud,
},
});
}}
>
Добавить
</Button>
</Group>
<DataTable
noHeader
withTableBorder
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, name }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{name}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (placeType) => (
<PlaceTypeActions placeType={placeType} />
),
},
]}
records={placeTypes.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<PlaceType placeType={record} />
),
}}
/>
</Stack>
);
};
export default PlaceTypesEditor;

View File

@@ -0,0 +1,28 @@
import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
import { FC } from "react";
export enum WmsPage {
PLACE,
PLACE_TYPE,
}
type Props = Omit<SegmentedControlProps, "data">;
const data = [
{
label: "Места на складе",
value: WmsPage.PLACE.toString(),
},
{
label: "Типы мест на складе",
value: WmsPage.PLACE_TYPE.toString(),
},
];
export const WmsSegmentedControl: FC<Props> = props => {
return (
<SegmentedControl
data={data}
{...props}
/>
);
};

View File

@@ -0,0 +1,120 @@
import React, { createContext, FC, useContext } from "react";
import { EditPlaceSchema, PlaceSchema, PlaceTypeSchema, WmsService } from "../../../../../../client";
import usePlaceTypesList from "../hooks/usePlaceTypesList.tsx";
import usePlaceTypesCrud, { PlaceTypeCrud } from "../hooks/usePlaceTypesCrud.tsx";
import usePlacesList from "../../place/hooks/usePlacesList.tsx";
import usePlacesCrud, { PlaceCrud } from "../../place/hooks/usePlacesCrud.tsx";
import { modals } from "@mantine/modals";
type WarehouseManagementTabContextState = {
refetchPlaceTypes: () => void;
placeTypeCrud: PlaceTypeCrud;
placeTypes: PlaceTypeSchema[];
refetchPlaces: () => void;
placeCrud: PlaceCrud;
places: PlaceSchema[];
onCreatePlace: (place?: PlaceSchema) => void;
onEditPlace: (place?: EditPlaceSchema) => void;
generateQrCode: (place: PlaceSchema) => void;
};
const WarehouseManagementTabContext = createContext<WarehouseManagementTabContextState | undefined>(
undefined,
);
const useWarehouseManagementTabContextState = () => {
const { objects: placeTypes, refetch: refetchPlaceTypes } = usePlaceTypesList();
const { objects: places, refetch: refetchPlaces } = usePlacesList();
const placeTypeCrud: PlaceTypeCrud = usePlaceTypesCrud({
fetchPlaceTypes: refetchPlaceTypes,
});
const placeCrud: PlaceCrud = usePlacesCrud({
fetchPlaces: refetchPlaces,
});
const onCreatePlace = (place?: PlaceSchema) => {
WmsService.getFlatPlaceTypes({
parentPlaceTypeId: place?.placeTypeId || -1,
})
.then(({ placeTypes }) => {
if (placeTypes.length === 1) {
placeCrud.onCreate({
parentId: place?.id || null,
placeTypeId: placeTypes[0].id,
});
return;
}
modals.openContextModal({
modal: "placeModal",
withCloseButton: false,
innerProps: {
placeCrud,
parent: place,
placeTypes,
},
});
})
.catch(err => console.log(err));
};
const onEditPlace = (place?: EditPlaceSchema) => {
modals.openContextModal({
modal: "placeModal",
withCloseButton: false,
title: "Редактирование",
innerProps: {
placeCrud,
element: place,
},
});
};
const generateQrCode = (place: PlaceSchema) => {
modals.openContextModal({
modal: "selectPlaceQrType",
withCloseButton: false,
centered: true,
innerProps: {
place,
},
});
};
return {
placeTypes,
placeTypeCrud,
refetchPlaceTypes,
places,
placeCrud,
refetchPlaces,
onCreatePlace,
onEditPlace,
generateQrCode,
};
};
type WarehouseManagementTabContextProviderProps = {
children: React.ReactNode;
};
export const WarehouseManagementTabContextProvider: FC<WarehouseManagementTabContextProviderProps> = ({ children }) => {
const state = useWarehouseManagementTabContextState();
return (
<WarehouseManagementTabContext.Provider value={state}>
{children}
</WarehouseManagementTabContext.Provider>
);
};
export const useWarehouseManagementTabContext = () => {
const context = useContext(WarehouseManagementTabContext);
if (!context) {
throw new Error(
"useWarehouseManagementTabContext must be used within a WarehouseManagementTabContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const useFlatPlaceTypesList = (parentPlaceTypeId: number = -1) =>
ObjectList({
queryFn: () => WmsService.getFlatPlaceTypes({ parentPlaceTypeId }),
getObjectsFn: response => response.placeTypes,
queryKey: "getFlatPlaceTypes",
});
export default useFlatPlaceTypesList;

View File

@@ -0,0 +1,56 @@
import { useCRUD } from "../../../../../../hooks/useCRUD.tsx";
import { BasePlaceTypeSchema, FlatPlaceTypeSchema, WmsService } from "../../../../../../client";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
export type PlaceTypeCrud = {
onCreate: (element: BasePlaceTypeSchema) => void,
onDelete: (element: FlatPlaceTypeSchema) => void,
onChange: (element: FlatPlaceTypeSchema) => void
}
type Props = {
fetchPlaceTypes: () => void;
}
const usePlaceTypesCrud = ({ fetchPlaceTypes }: Props): PlaceTypeCrud => {
return useCRUD<FlatPlaceTypeSchema, BasePlaceTypeSchema>({
onChange: (placeType: FlatPlaceTypeSchema) => {
WmsService.editPlaceType({
requestBody: { placeType },
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onDelete: (placeType: FlatPlaceTypeSchema) => {
WmsService.deletePlaceType({
placeTypeId: placeType.id,
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onCreate: (placeType: BasePlaceTypeSchema) => {
WmsService.createPlaceType({
requestBody: {
placeType,
},
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
});
};
export default usePlaceTypesCrud;

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const usePlaceTypesList = () =>
ObjectList({
queryFn: WmsService.getPlaceTypes,
getObjectsFn: response => response.placeTypes,
queryKey: "getAllPlaceTypes",
});
export default usePlaceTypesList;

View File

@@ -0,0 +1,76 @@
import { ContextModalProps } from "@mantine/modals";
import { BasePlaceTypeSchema, PlaceTypeSchema } from "../../../../../../client";
import { PlaceTypeCrud } from "../hooks/usePlaceTypesCrud.tsx";
import { Button, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
type Props = {
placeTypeCrud: PlaceTypeCrud;
element?: PlaceTypeSchema & BasePlaceTypeSchema;
parent?: PlaceTypeSchema;
}
type PlaceTypeModalForm = {
name: string;
}
const PlaceTypeModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const { element, parent, placeTypeCrud } = innerProps;
const closeModal = () => {
context.closeContextModal(id);
};
const initialValues: PlaceTypeModalForm = {
name: innerProps.element?.name ?? "",
};
const form = useForm<PlaceTypeModalForm>({
initialValues,
validate: {
name: name => !name && "Необходимо указать название",
},
});
const isChanged = (): boolean => {
return initialValues.name !== form.values.name;
};
const onSubmit = (values: PlaceTypeModalForm) => {
if (element) {
if (isChanged()) {
placeTypeCrud.onChange({
name: values.name ?? "",
id: element?.id,
parentId: element?.parentId ?? null,
});
}
} else {
placeTypeCrud.onCreate({
name: values.name ?? "",
parentId: parent?.id ?? null,
});
}
closeModal();
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
/>
<Button variant={"default"} type={"submit"}>
Сохранить
</Button>
</Stack>
</form>
);
};
export default PlaceTypeModal;

View File

@@ -7,7 +7,7 @@ import { processSelectedCells } from "../../../../../shared/lib/interpolateCells
import { TimeTrackingData } from "../../../../../client";
import dayjs from "dayjs";
import { IMaskInput } from "react-imask";
import { floatHoursToHoursAndMinutes } from "../../../../../types/utils.ts";
import { dateToString, floatHoursToHoursAndMinutes } from "../../../../../types/utils.ts";
export type EmployeeData = {
name: string;
@@ -20,21 +20,17 @@ export type EmployeeData = {
[key: string]: number | string;
};
type Props = {
month: Date;
data: EmployeeData[];
onUpdate: (date: Date, userId: number, value: string) => void;
selectedCells: string[];
setSelectedCells: (cells: string[]) => void;
selectedBoundaries: [Date | null, Date | null];
range: dayjs.Dayjs[];
};
const useWorkTableColumns = ({
month,
onUpdate,
data,
selectedCells,
setSelectedCells,
selectedBoundaries,
range,
}: Props) => {
const totalAmount = useMemo(() => {
@@ -48,7 +44,7 @@ const useWorkTableColumns = ({
}
return acc;
}, 0);
}, [data, month, selectedCells, selectedBoundaries]);
}, [data, selectedCells, range]);
const getBorderStyles = (cellId: string) => {
if (selectedCells.length <= 1) return {};
if (selectedCells[0] === cellId)
@@ -86,8 +82,8 @@ const useWorkTableColumns = ({
...range.map(date => ({
size: 100,
accessorKey: date.date().toString(),
header: date.date().toString(),
accessorKey: dateToString(date.toDate()) ?? "",
header: dateToString(date.toDate()) ?? "",
enableSorting: false,
enableColumnActions: false,
Header: (
@@ -130,7 +126,7 @@ const useWorkTableColumns = ({
row.original.userId,
value,
);
}
};
return (
<Flex direction={"column"}>
@@ -178,7 +174,7 @@ const useWorkTableColumns = ({
),
},
],
[month, selectedCells, selectedBoundaries, totalAmount],
[selectedCells, range, totalAmount],
);
};

View File

@@ -1,49 +1,45 @@
import { useEffect, useState } from "react";
import { TimeTrackingRecord, TimeTrackingService } from "../../../../../client";
import {
dateWithoutTimezone,
getDatesInMonth,
} from "../../../../../shared/lib/date.ts";
import { last } from "lodash";
import { getDefaultEndDate } from "../../WorkShiftsPlanning/utils/utils.tsx";
import { dateToString } from "../../../../../types/utils.ts";
const getDateBoundaries = (month: Date) => {
return [
getDatesInMonth(month)[0].toDate(),
last(getDatesInMonth(month))?.toDate(),
];
};
const useWorkTableState = () => {
const [month, setMonth] = useState<Date>(
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
);
const [dateRange, setDateRange] = useState<
[Date | null, Date | null]
>([new Date(), getDefaultEndDate()]);
const [trackingRecords, setTrackingRecords] = useState<
TimeTrackingRecord[]
>([]);
const [dateBoundaries, setDateBoundaries] = useState(
getDateBoundaries(month)
);
const refetch = async () => {
return TimeTrackingService.getTimeTrackingRecords({
requestBody: {
date: dateWithoutTimezone(month),
userIds: [],
},
}).then(response => setTrackingRecords(response.records));
const refetch = () => {
const ending = "T00:00:00";
const dateFrom = dateToString(dateRange[0]);
const dateTo = dateToString(dateRange[1]);
if (!(dateFrom && dateTo)) return;
TimeTrackingService
.getTimeTrackingRecords({
requestBody: {
dateFrom: dateFrom + ending,
dateTo: dateTo + ending,
},
})
.then(response => {
console.log(response.records);
setTrackingRecords(response.records);
})
.catch(err => console.log(err));
};
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
refetch().then(_ => {
setDateBoundaries(getDateBoundaries(month));
});
}, [month]);
refetch();
}, [dateRange]);
return {
month,
setMonth,
dateRange,
setDateRange,
refetch,
trackingRecords,
dateBoundaries,
};
};

View File

@@ -1,56 +1,44 @@
import { ActionIcon, Flex, MultiSelect, rem, Tooltip } from "@mantine/core";
import { DatePickerInput, MonthPickerInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import useWorkTableState from "../hooks/useWorkTableState.tsx";
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import { useEffect, useState } from "react";
import useWorkTableColumns, { EmployeeData } from "../hooks/useWorkTableColumns.tsx";
import { TimeTrackingRecord, TimeTrackingService, UserSchema } from "../../../../../client";
import { dateWithoutTimezone, getDatesBetween, getDatesInMonth } from "../../../../../shared/lib/date.ts";
import { getDatesBetween } from "../../../../../shared/lib/date.ts";
import useUsersList from "../../../hooks/useUsersList.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { PaySchemeType } from "../../../../../shared/enums/PaySchemeType.ts";
import { IconEyeOff } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { difference, omit } from "lodash";
import { strTimeToFloatHours } from "../../../../../types/utils.ts";
import { dateToString, strTimeToFloatHours } from "../../../../../types/utils.ts";
import { useListState } from "@mantine/hooks";
const WorkTimeTable = () => {
const [data, setData] = useState<EmployeeData[]>([]);
const { dateBoundaries, month, setMonth, trackingRecords, refetch } =
useWorkTableState();
const { dateRange, setDateRange, trackingRecords, refetch } = useWorkTableState();
const [shownUsers, shownUsersHandlers] = useListState<UserSchema>([]);
const [selectedBoundaries, setSelectedBoundaries] = useState<
[Date | null, Date | null]
>([null, null]);
const users = useUsersList().objects.filter(
user => user.payRate?.payrollScheme.key === PaySchemeType.HOURLY,
);
const getRange = () => {
const startDate = selectedBoundaries[0];
const endDate = selectedBoundaries[1];
if (startDate && endDate) {
return getDatesBetween(startDate, endDate);
} else {
return getDatesInMonth(month);
}
if (!(dateRange.length === 2 && dateRange[0] && dateRange[1])) return [];
const startDate = dateRange[0];
const endDate = dateRange[1];
return getDatesBetween(startDate, endDate);
};
const range = getRange();
const transformTrackingRecordsToData = (
trackingRecords: TimeTrackingRecord[],
): EmployeeData[] => {
if (!month) return [];
const rangeDays = range.map(r => r.date());
if (!(dateRange.length === 2 && dateRange[0] && dateRange[1])) return [];
const dateFrom = dateRange[0];
const dateTo = dateRange[1];
const dates = getDatesBetween(dateFrom, dateTo);
trackingRecords = trackingRecords.map(tr => ({
...tr,
data: tr.data.filter(d =>
rangeDays.includes(new Date(d.date).getDate()),
),
}));
const existingUserIds = trackingRecords.map(tr => tr.user.id);
const firstResult = trackingRecords.map(record => ({
name: `${record.user.firstName} ${record.user.secondName}`,
@@ -59,14 +47,14 @@ const WorkTimeTable = () => {
totalAmount: record.totalAmount,
data: record.data,
...Object.fromEntries(
getDatesInMonth(month).reduce((acc, day) => {
return acc.set(day.date().toString(), 0);
dates.reduce((acc, day) => {
return acc.set(dateToString(day.toDate()) ?? "", 0);
}, new Map<string, number>()),
),
...Object.fromEntries(
record.data.reduce((acc, recordData) => {
return acc.set(
new Date(recordData.date).getDate().toString(),
recordData.date,
recordData.hours,
);
}, new Map<string, number>()),
@@ -80,7 +68,7 @@ const WorkTimeTable = () => {
comment: user.comment,
totalAmount: 0,
...Object.fromEntries(
getDatesInMonth(month).reduce((acc, day) => {
dates.reduce((acc, day) => {
return acc.set(day.date().toString(), 0);
}, new Map<string, number>()),
),
@@ -93,13 +81,12 @@ const WorkTimeTable = () => {
// @ts-expect-error
.concat(restUsersResult)
.filter(r => shownUserIds.includes(r.userId));
const firstDate = selectedBoundaries[0];
const lastDate = selectedBoundaries[1];
if (firstDate && lastDate) {
const allDays = getDatesInMonth(month).map(d =>
if (dateFrom && dateTo) {
const allDays = dates.map(d =>
d.date().toString(),
);
const allowedDays = getDatesBetween(firstDate, lastDate).map(d =>
const allowedDays = getDatesBetween(dateFrom, dateTo).map(d =>
d.date().toString(),
);
const omitDays = difference(allDays, allowedDays);
@@ -117,10 +104,7 @@ const WorkTimeTable = () => {
const user = users.find(user => user.id === userId);
if (!user) return;
console.log(value);
const hours = strTimeToFloatHours(value);
console.log(hours);
if (hours === -1) return;
setData(prevState =>
@@ -128,12 +112,13 @@ const WorkTimeTable = () => {
if (record.userId !== userId) return record;
record[date.getDate()] = value;
return record;
})
}),
);
const ending = "T00:00:00";
TimeTrackingService.updateTimeTrackingRecord({
requestBody: {
date: dateWithoutTimezone(date),
date: dateToString(date) + ending,
hours,
userId: user.id,
},
@@ -141,13 +126,11 @@ const WorkTimeTable = () => {
if (!ok) {
notifications.guess(ok, { message });
}
await refetch();
refetch();
});
};
const columns = useWorkTableColumns({
month,
selectedBoundaries,
data,
onUpdate: optimisticUpdate,
selectedCells: [],
@@ -158,11 +141,7 @@ const WorkTimeTable = () => {
useEffect(() => {
setData(transformTrackingRecordsToData(trackingRecords));
}, [trackingRecords, shownUsers, selectedBoundaries]);
useEffect(() => {
setSelectedBoundaries([null, null]);
}, [month]);
}, [trackingRecords, shownUsers, dateRange]);
return (
<Flex
@@ -193,55 +172,45 @@ const WorkTimeTable = () => {
/>
<Flex gap={rem(10)}>
<DatePickerInput
styles={{
input: {
textAlign: "center",
},
}}
miw={rem(80)}
valueFormat={"DD"}
placeholder={"Выберите временной промежуток"}
type={"range"}
minDate={dateBoundaries[0]}
maxDate={dateBoundaries[1]}
value={selectedBoundaries}
onChange={setSelectedBoundaries}
placeholder={"Даты"}
/>
<MonthPickerInput
allowDeselect={false}
onChange={event => event && setMonth(event)}
value={month}
placeholder={"Выберите месяц"}
value={dateRange}
onChange={(value) => {
setDateRange(value);
}}
/>
</Flex>
</Flex>
<Flex>
<BaseTable
data={data}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Скрыть">
<ActionIcon
onClick={() => {
shownUsersHandlers.filter(
user => user.id !== row.original.userId,
);
}}
variant={"default"}>
<IconEyeOff />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<EmployeeData>
}
/>
{dateRange[1] !== null && (
<BaseTable
data={data}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Скрыть">
<ActionIcon
onClick={() => {
shownUsersHandlers.filter(
user => user.id !== row.original.userId,
);
}}
variant={"default"}>
<IconEyeOff />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<EmployeeData>
}
/>
)}
</Flex>
</Flex>
);

View File

@@ -1,19 +1,27 @@
import { useParams } from "@tanstack/react-router";
import { CardPageContextProvider, useCardPageContext } from "../../CardsPage/contexts/CardPageContext.tsx";
import ProductAndServiceTab from "../../../modules/cardModules/cardEditorTabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import ProductAndServiceTab
from "../../../modules/cardModules/cardEditorTabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import React, { FC, useEffect } from "react";
import { CardService } from "../../../client";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
export type Props = {
cardId: number;
};
const CardPageContent: FC<Props> = ({ cardId }) => {
const { setSelectedCard } = useCardPageContext();
const authState = useSelector((state: RootState) => state.auth);
useEffect(() => {
CardService.getCardById({ cardId }).then(card => {
setSelectedCard(card);
});
}, []);
if (authState.isAuthorized) {
CardService.getCardById({ cardId }).then(card => {
setSelectedCard(card);
});
}
}, [authState.isAuthorized]);
return <ProductAndServiceTab />;
};

View File

@@ -1,19 +1,22 @@
import { ActionIcon, Flex, rem, Text } from "@mantine/core";
import { IconEdit, IconMenu2, IconMenuDeep, IconPlus } from "@tabler/icons-react";
import { ActionIcon, Button, Flex, rem, Text, Tooltip } from "@mantine/core";
import { IconEdit, IconLogout, IconMenu2, IconMenuDeep, IconPlus } from "@tabler/icons-react";
import { motion } from "framer-motion";
import styles from "../../ui/CardsPage.module.css";
import PageBlock from "../../../../components/PageBlock/PageBlock.tsx";
import DisplayMode from "../../enums/DisplayMode.ts";
import { UseFormReturnType } from "@mantine/form";
import { CardsPageState } from "../../hooks/useCardsPageState.tsx";
import { CardsPageState } from "../../hooks/useCardsTableForm.tsx";
import React from "react";
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";
import { RootState, useAppDispatch } from "../../../../redux/store.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { modals } from "@mantine/modals";
import classes from "../../../../components/Navbar/Navbar.module.css";
import { logout } from "../../../../features/authSlice.ts";
import { useNavigate } from "@tanstack/react-router";
type Props = {
displayMode: DisplayMode;
@@ -28,7 +31,9 @@ const CardsPageHeader = ({
}: Props) => {
const { openProjectsEditor } = useProjectsEditorContext();
const { selectedProject, setSelectedProject, projects, refetchProjects } = useProjectsContext();
const userRole = useSelector((state: RootState) => state.auth.role);
const { role: userRole, isDealsViewer } = useSelector((state: RootState) => state.auth);
const navigate = useNavigate();
const dispatch = useAppDispatch();
const isAdmin = userRole === "admin";
const handleCreateClick = () => {
@@ -42,6 +47,31 @@ const CardsPageHeader = ({
});
};
const onLogoutClick = () => {
dispatch(logout());
navigate({ to: "/login" });
};
const getLogoutButton = () => {
return (
<Tooltip
display={"flex"}
label={"Выйти"}
position="right"
transitionProps={{ duration: 0 }}>
<Button
variant={"default"}
onClick={() => onLogoutClick()}
className={classes.link}>
<IconLogout
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
</Button>
</Tooltip>
);
};
const getHeaderInputsBoard = () => {
return (
<div
@@ -66,12 +96,16 @@ const CardsPageHeader = ({
</ActionIcon>
</>
)}
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}
value={selectedProject}
onChange={setSelectedProject}
/>
{isDealsViewer ? (
getLogoutButton()
) : (
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}
value={selectedProject}
onChange={setSelectedProject}
/>
)}
</div>
);
};

View File

@@ -1,19 +1,27 @@
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { CardService, CardSummary } from "../../../../client";
import { FC } from "react";
import { FC, useEffect, useRef } from "react";
import useCardsTableColumns from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { ActionIcon, Flex, Group, Pagination, Stack, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import useCardSummaries from "../../hooks/useCardSummaries.tsx";
import { CardsPageState } from "../../hooks/useCardsTableForm.tsx";
import { UseFormReturnType } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
type RestProps = {
viewOnly?: boolean;
form: UseFormReturnType<CardsPageState>;
};
type Props = CRUDTableProps<CardSummary> & RestProps;
type Props = Omit<CRUDTableProps<CardSummary>, "items"> & RestProps;
const CardsTable: FC<Props> = ({ form, onSelectionChange, viewOnly = false }) => {
const [debouncedForm] = useDebouncedValue(form, 300);
const prevDebouncedValues = useRef(debouncedForm?.values);
const CardsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) => {
const columns = useCardsTableColumns();
const { setSelectedCard } = useCardPageContext();
const onEditClick = (cardSummary: CardSummary) => {
@@ -22,35 +30,65 @@ const CardsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) =
});
};
const {
summaries,
fetchSummaries,
totalPages,
page,
setPage,
} = useCardSummaries({
full: true,
values: form?.values,
withPagination: true,
});
useEffect(() => {
if (prevDebouncedValues.current !== debouncedForm.values) {
prevDebouncedValues.current = debouncedForm.values;
setPage(1);
}
fetchSummaries();
}, [debouncedForm.values, page]);
return (
<BaseTable
data={items}
columns={columns}
onSelectionChange={onSelectionChange}
restProps={
{
enableSorting: true,
enableColumnActions: false,
enablePagination: !viewOnly,
enableBottomToolbar: !viewOnly,
paginationDisplayMode: "pages",
enableRowActions: true,
enableRowSelection: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
disabled={viewOnly}
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<CardSummary>
}
/>
<Stack>
<BaseTable
data={summaries}
columns={columns}
onSelectionChange={onSelectionChange}
restProps={
{
enableSorting: true,
enableColumnActions: false,
paginationDisplayMode: "pages",
enableRowActions: true,
enableRowSelection: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
disabled={viewOnly}
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<CardSummary>
}
/>
{!viewOnly && (
<Group justify={"flex-end"}>
<Pagination
withEdges
total={totalPages}
value={page}
onChange={setPage}
/>
</Group>
)}
</Stack>
);
};

View File

@@ -4,7 +4,7 @@ import { CardSchema, CardService } from "../../../client";
type CardPageContextState = {
selectedCard?: CardSchema;
setSelectedCard: (card: CardSchema | undefined) => void;
refetchCards: () => Promise<void>;
refetchCards: () => void;
refetchCard: () => void;
};
@@ -13,7 +13,7 @@ const CardPageContext = createContext<CardPageContextState | undefined>(
);
type CardPageContextStateProps = {
refetchCards: () => Promise<void>;
refetchCards: () => void;
defaultCardId?: number;
}

View File

@@ -11,26 +11,26 @@ type DndContextState = {
dragState: DragState,
onDragStart: (start: DragStart) => void,
onDragEnd: (result: DropResult) => Promise<void>,
refetchSummaries: () => void;
};
const DndContext = createContext<DndContextState | undefined>(undefined);
type DndContextProps = {
summariesRaw: CardSummary[];
summaries: CardSummary[];
refetchSummaries: () => void;
}
const useDndContextState = ({
summariesRaw,
summaries,
refetchSummaries,
}: DndContextProps) => {
const [dragState, setDragState] = useState<DragState>(DragState.DRAG_ENDED);
const {
summaries,
onCardDragEnd,
} = useCardsDnd({
summariesRaw,
summaries,
refetchSummaries,
});
@@ -64,6 +64,7 @@ const useDndContextState = ({
dragState,
onDragStart,
onDragEnd,
refetchSummaries,
};
};

Some files were not shown because too many files have changed in this diff Show More