feat: barcodes printing

This commit is contained in:
2025-10-10 20:47:44 +04:00
parent 8af4fcce2f
commit 73e3fd4ba2
14 changed files with 313 additions and 13 deletions

View File

@ -74,7 +74,7 @@ export default function RootLayout({ children }: Props) {
layout={"alt"}
withBorder={false}
navbar={{
width: 250,
width: 220,
breakpoint: "sm",
collapsed: {
desktop: false,

View File

@ -15,6 +15,9 @@ const BarcodeTemplateSelect: FC<Props> = props => {
return (
<ObjectSelect
data={barcodeTemplates}
getLabelFn={template =>
`${template.name} (${template.attributes.map(a => a.name).join(", ")})`
}
{...props}
/>
);

View File

@ -10,11 +10,12 @@ const ProductsTable = () => {
const isMobile = useIsMobile();
const { productsCrud, products, productsFiltersForm, paginationInfo } =
useProductsContext();
const { onChangeClick } = useProductsActions();
const { onChangeClick, onPrintBarcodeClick } = useProductsActions();
const columns = useProductsTableColumns({
onChange: onChangeClick,
onDelete: productsCrud.onDelete,
onPrintBarcode: onPrintBarcodeClick,
});
return (

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { IconBarcode, IconEdit, IconTrash } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Center, Flex, List, Spoiler, useMantineTheme } from "@mantine/core";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
@ -7,11 +7,16 @@ import useIsMobile from "@/hooks/utils/useIsMobile";
import { ProductSchema } from "@/lib/client";
type Props = {
onPrintBarcode: (product: ProductSchema) => void;
onChange: (product: ProductSchema) => void;
onDelete: (product: ProductSchema) => void;
};
export const useProductsTableColumns = ({ onChange, onDelete }: Props) => {
export const useProductsTableColumns = ({
onChange,
onDelete,
onPrintBarcode,
}: Props) => {
const theme = useMantineTheme();
const isMobile = useIsMobile();
@ -24,11 +29,11 @@ export const useProductsTableColumns = ({ onChange, onDelete }: Props) => {
width: "0%",
render: product => (
<Flex gap={isMobile ? "sm" : "md"}>
{/*<ActionIconWithTip*/}
{/* tipLabel={"Печать штрихкода"}*/}
{/* onClick={() => onPrintBarcodeClick(product)}>*/}
{/* <IconBarcode />*/}
{/*</ActionIconWithTip>*/}
<ActionIconWithTip
tipLabel={"Печать штрихкода"}
onClick={() => onPrintBarcode(product)}>
<IconBarcode />
</ActionIconWithTip>
<ActionIconWithTip
tipLabel={"Редактировать"}
onClick={() => onChange(product)}>

View File

@ -40,11 +40,12 @@ const useProductsActions = () => {
const onPrintBarcodeClick = (product: ProductSchema) => {
modals.openContextModal({
modal: "printBarcode",
title: "Печать штрихкода", // TODO
modal: "printBarcodeModal",
title: "Печать штрихкода",
withCloseButton: true,
innerProps: {
productId: product.id,
product,
defaultQuantity: 1,
},
});
};

View File

@ -35,7 +35,7 @@ const linksData: LinkData[] = [
},
{
icon: IconFileBarcode,
label: "Шаблоны штрихкодов",
label: "Шаблоны ШК",
href: "/barcode-templates",
moduleName: ModuleNames.FULFILLMENT_BASE,
},

View File

@ -47,6 +47,7 @@ import {
getDealProducts,
getDeals,
getDealServices,
getProductBarcodePdf,
getProducts,
getProjects,
getServiceCategories,
@ -168,6 +169,9 @@ import type {
GetDealsError,
GetDealServicesData,
GetDealsResponse2,
GetProductBarcodePdfData,
GetProductBarcodePdfError,
GetProductBarcodePdfResponse2,
GetProductsData,
GetProductsError,
GetProductsResponse2,
@ -1867,6 +1871,57 @@ export const updateProductMutation = (
return mutationOptions;
};
export const getProductBarcodePdfQueryKey = (
options: Options<GetProductBarcodePdfData>
) => createQueryKey("getProductBarcodePdf", options);
/**
* Get Product Barcode Pdf
*/
export const getProductBarcodePdfOptions = (
options: Options<GetProductBarcodePdfData>
) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getProductBarcodePdf({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getProductBarcodePdfQueryKey(options),
});
};
/**
* Get Product Barcode Pdf
*/
export const getProductBarcodePdfMutation = (
options?: Partial<Options<GetProductBarcodePdfData>>
): UseMutationOptions<
GetProductBarcodePdfResponse2,
AxiosError<GetProductBarcodePdfError>,
Options<GetProductBarcodePdfData>
> => {
const mutationOptions: UseMutationOptions<
GetProductBarcodePdfResponse2,
AxiosError<GetProductBarcodePdfError>,
Options<GetProductBarcodePdfData>
> = {
mutationFn: async localOptions => {
const { data } = await getProductBarcodePdf({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getServicesQueryKey = (options?: Options<GetServicesData>) =>
createQueryKey("getServices", options);

View File

@ -113,6 +113,9 @@ import type {
GetDealServicesErrors,
GetDealServicesResponses,
GetDealsResponses,
GetProductBarcodePdfData,
GetProductBarcodePdfErrors,
GetProductBarcodePdfResponses,
GetProductsData,
GetProductsErrors,
GetProductsResponses,
@ -247,6 +250,8 @@ import {
zGetDealServicesData,
zGetDealServicesResponse2,
zGetDealsResponse2,
zGetProductBarcodePdfData,
zGetProductBarcodePdfResponse2,
zGetProductsData,
zGetProductsResponse2,
zGetProjectsData,
@ -1458,6 +1463,33 @@ export const updateProduct = <ThrowOnError extends boolean = false>(
});
};
/**
* Get Product Barcode Pdf
*/
export const getProductBarcodePdf = <ThrowOnError extends boolean = false>(
options: Options<GetProductBarcodePdfData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).post<
GetProductBarcodePdfResponses,
GetProductBarcodePdfErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zGetProductBarcodePdfData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zGetProductBarcodePdfResponse2.parseAsync(data);
},
url: "/modules/fulfillment-base/product/barcode/get-pdf",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Get Services
*/

View File

@ -1102,6 +1102,42 @@ export type GetDealsResponse = {
paginationInfo: PaginationInfoSchema;
};
/**
* GetProductBarcodePdfRequest
*/
export type GetProductBarcodePdfRequest = {
/**
* Quantity
*/
quantity: number;
/**
* Productid
*/
productId: number;
/**
* Barcode
*/
barcode: string;
};
/**
* GetProductBarcodePdfResponse
*/
export type GetProductBarcodePdfResponse = {
/**
* Base64String
*/
base64String: string;
/**
* Filename
*/
filename: string;
/**
* Mimetype
*/
mimeType: string;
};
/**
* GetProductsResponse
*/
@ -3379,6 +3415,33 @@ export type UpdateProductResponses = {
export type UpdateProductResponse2 =
UpdateProductResponses[keyof UpdateProductResponses];
export type GetProductBarcodePdfData = {
body: GetProductBarcodePdfRequest;
path?: never;
query?: never;
url: "/modules/fulfillment-base/product/barcode/get-pdf";
};
export type GetProductBarcodePdfErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetProductBarcodePdfError =
GetProductBarcodePdfErrors[keyof GetProductBarcodePdfErrors];
export type GetProductBarcodePdfResponses = {
/**
* Successful Response
*/
200: GetProductBarcodePdfResponse;
};
export type GetProductBarcodePdfResponse2 =
GetProductBarcodePdfResponses[keyof GetProductBarcodePdfResponses];
export type GetServicesData = {
body?: never;
path?: never;

View File

@ -767,6 +767,24 @@ export const zGetDealsResponse = z.object({
paginationInfo: zPaginationInfoSchema,
});
/**
* GetProductBarcodePdfRequest
*/
export const zGetProductBarcodePdfRequest = z.object({
quantity: z.int(),
productId: z.int(),
barcode: z.string(),
});
/**
* GetProductBarcodePdfResponse
*/
export const zGetProductBarcodePdfResponse = z.object({
base64String: z.string(),
filename: z.string(),
mimeType: z.string(),
});
/**
* GetProductsResponse
*/
@ -1778,6 +1796,17 @@ export const zUpdateProductData = z.object({
*/
export const zUpdateProductResponse2 = zUpdateProductResponse;
export const zGetProductBarcodePdfData = z.object({
body: zGetProductBarcodePdfRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetProductBarcodePdfResponse2 = zGetProductBarcodePdfResponse;
export const zGetServicesData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),

View File

@ -13,6 +13,7 @@ import {
DealProductEditorModal,
DealServiceEditorModal,
DuplicateServicesModal,
PrintBarcodeModal,
ProductEditorModal,
ProductServiceEditorModal,
ServicesKitSelectModal,
@ -34,4 +35,5 @@ export const modals = {
serviceEditorModal: ServiceEditorModal,
barcodeTemplateEditorModal: BarcodeTemplateEditorModal,
clientEditorModal: ClientEditorModal,
printBarcodeModal: PrintBarcodeModal,
};

View File

@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Flex, NumberInput, Select } from "@mantine/core";
import { ContextModalProps } from "@mantine/modals";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import { ProductSchema } from "@/lib/client";
import { getProductBarcodePdfMutation } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
import base64ToBlob from "@/utils/base64ToBlob";
type Props = {
product: ProductSchema;
defaultQuantity?: number;
};
const PrintBarcodeModal = ({ innerProps }: ContextModalProps<Props>) => {
const { product, defaultQuantity = 1 } = innerProps;
const [quantity, setQuantity] = useState(defaultQuantity);
const [barcode, setBarcode] = useState<string | null>(null);
const [barcodeImageUrl, setBarcodeImageUrl] = useState<
string | undefined
>();
const getBarcodePdfMutation = useMutation({
...getProductBarcodePdfMutation(),
onSuccess: response => {
const pdfBlob = base64ToBlob(
response.base64String,
response.mimeType
);
const pdfUrl = URL.createObjectURL(pdfBlob);
const pdfWindow = window.open(pdfUrl);
if (!pdfWindow) {
notifications.error({ message: "Ошибка" });
return;
}
pdfWindow.onload = () => pdfWindow.print();
},
});
const printBarcode = () => {
if (!barcode) return;
getBarcodePdfMutation.mutate({
body: {
barcode,
quantity,
productId: product.id,
},
});
};
return (
<Flex
gap={"xs"}
direction={"column"}>
{barcodeImageUrl ? (
<object
style={{
alignSelf: "center",
aspectRatio:
product.barcodeTemplate.size.width /
product.barcodeTemplate.size.height,
width: "240px",
}}
data={barcodeImageUrl}>
Ошибка загрузки штрихкода
</object>
) : (
<Select
value={barcode}
onChange={value => setBarcode(value)}
data={product?.barcodes}
label={"Штрихкод"}
placeholder={"Выберите штрихкод"}
/>
)}
<NumberInput
label={"Количество копий"}
placeholder={"Введите количество копий"}
value={quantity}
onChange={value => setQuantity(Number(value))}
allowNegative={false}
min={1}
/>
<InlineButton
disabled={!barcode}
mt={"xs"}
onClick={printBarcode}>
Печать
</InlineButton>
</Flex>
);
};
export default PrintBarcodeModal;

View File

@ -4,3 +4,4 @@ export { default as DuplicateServicesModal } from "@/modules/dealModularEditorTa
export { default as DealServiceEditorModal } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals/DealServiceEditorModal/DealServiceEditorModal";
export { default as DealProductEditorModal } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals/DealProductEditorModal/DealProductEditorModal";
export { default as ProductServiceEditorModal } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals/ProductServiceEditorModal/ProductServiceEditorModal";
export { default as PrintBarcodeModal } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals/PrintBarcodeModal/PrintBarcodeModal";

11
src/utils/base64ToBlob.ts Normal file
View File

@ -0,0 +1,11 @@
function base64ToBlob(base64: string, type: string) {
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; ++i) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type });
}
export default base64ToBlob;