This commit is contained in:
David Arranz 2024-08-18 22:39:06 +02:00
parent 0ff5c39023
commit d298754ee5
18 changed files with 369 additions and 40 deletions

View File

@ -44,6 +44,7 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-table": "^8.20.1",
"@wojtekmaj/react-hooks": "^1.21.0",
"axios": "^1.7.3",
"class-variance-authority": "^0.7.0",
"cmdk": "^1.0.0",
@ -52,6 +53,7 @@
"i18next-browser-languagedetector": "^8.0.0",
"joi": "^17.13.1",
"lucide-react": "^0.427.0",
"print-js": "^1.6.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-currency-input-field": "^3.8.0",
@ -60,6 +62,7 @@
"react-hook-form": "^7.52.2",
"react-hook-form-persist": "^3.0.0",
"react-i18next": "^15.0.1",
"react-pdf": "^9.1.0",
"react-resizable-panels": "^2.0.23",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2",
@ -94,6 +97,7 @@
"ts-jest": "^29.2.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vite-plugin-static-copy": "^1.0.6"
}
}

View File

@ -1,21 +1,34 @@
import { Badge, Button, Card, CardContent } from "@/ui";
import { Badge, Button, Card, CardContent, CardHeader } from "@/ui";
import { DataTable, DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
import {
DataTable,
DataTableSkeleton,
ErrorOverlay,
PDFViewer,
SimpleEmptyState,
} from "@/components";
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { IListQuotes_Response_DTO, MoneyValue, UTCDateValue } from "@shared/contexts";
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { t } from "i18next";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Trans } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuotes } from "../hooks";
export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
export const QuotesDataTable = ({
status = "all",
className,
}: {
status?: string;
className?: string;
}) => {
const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext();
const { useList } = useQuotes();
const [focusedRow, setFocusedRow] = useState<Row<IListQuotes_Response_DTO>>();
const { useList, useReport } = useQuotes();
const { data, isPending, isError, error } = useList({
pagination: {
@ -26,6 +39,13 @@ export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
quickSearchTerm: globalFilter,
});
const {
data: reportData,
isPending: reportIsPending,
isError: reportIsError,
error: errorReport,
} = useReport(focusedRow ? focusedRow.original.id : undefined);
const columns = useMemo<ColumnDef<IListQuotes_Response_DTO, any>[]>(
() => [
{
@ -119,23 +139,31 @@ export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
pageCount: data?.total_pages ?? -1,
});
const handleOnRowClick = (row: Row<IListQuotes_Response_DTO>) => {
console.log("setFocusedRow", row.id);
setFocusedRow(row);
};
if (isError) {
return <ErrorOverlay subtitle={(error as Error).message} />;
}
if (isPending) {
return (
<Card>
<CardContent>
<DataTableSkeleton
columnCount={6}
searchableColumnCount={1}
filterableColumnCount={2}
//cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem"]}
shrinkZero
/>
</CardContent>
</Card>
<div className='grid items-start flex-1 gap-4 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'>
<Card className='grid items-start gap-4 auto-rows-max md:gap-8 lg:col-span-2'>
<CardContent>
<DataTableSkeleton
columnCount={6}
searchableColumnCount={1}
filterableColumnCount={2}
//cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem"]}
shrinkZero
/>
</CardContent>
</Card>
<div></div>
</div>
);
}
@ -149,11 +177,29 @@ export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
);
}
console.log(reportData);
return (
<>
<DataTable table={table} paginationOptions={{ visible: true }}>
<div className='grid items-start flex-1 gap-4 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'>
<DataTable
table={table}
paginationOptions={{ visible: true }}
className='grid items-start gap-4 auto-rows-max md:gap-8 lg:col-span-2'
onRowClick={handleOnRowClick}
>
<DataTableToolbar table={table} />
</DataTable>
</>
<div>
<Card className='overflow-hidden' x-chunk='dashboard-05-chunk-4'>
<CardHeader className='flex flex-row items-start bg-muted/50'></CardHeader>
<CardContent>
<PDFViewer
file={reportData}
className='aspect-[3/4] object-contain overflow-hidden border-2 border-dashed rounded-lg'
/>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { UseListQueryResult, useList, useOne, useSave } from "@/lib/hooks/useDataSource";
import { UseListQueryResult, useCustom, useList, useOne, useSave } from "@/lib/hooks/useDataSource";
import {
IFilterItemDataProviderParam,
IGetListDataProviderParams,
@ -12,6 +12,7 @@ import {
IGetQuote_Response_DTO,
IListQuotes_Response_DTO,
IListResponse_DTO,
IReportQuote_Response_DTO,
IUpdateQuote_Request_DTO,
IUpdateQuote_Response_DTO,
UniqueID,
@ -33,6 +34,11 @@ export type UseQuotesGetParamsType = {
queryOptions?: Record<string, unknown>;
};
export type UseQuotesReportParamsType = {
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
const quoteStatusFilter: Record<string, IFilterItemDataProviderParam> = {
draft: {
field: "status",
@ -85,7 +91,7 @@ export const useQuotes = () => {
useOne: (id?: string, params?: UseQuotesGetParamsType) =>
useOne<IGetQuote_Response_DTO>({
queryKey: keys().data().resource("quotes").action("one").id("").params().get(),
queryKey: keys().data().resource("quotes").action("one").id(id).params().get(),
queryFn: () =>
dataSource.getOne({
resource: "quotes",
@ -94,6 +100,7 @@ export const useQuotes = () => {
enabled: !!id,
...params,
}),
useCreate: () =>
useSave<ICreateQuote_Response_DTO, TDataSourceError, ICreateQuote_Request_DTO>({
//mutationKey: keys().data().resource("quotes").action("one").id("").params().get(),
@ -131,5 +138,21 @@ export const useQuotes = () => {
});
},
}),
useReport: (id?: string, params?: UseQuotesReportParamsType) =>
useCustom<IReportQuote_Response_DTO>({
queryKey: keys().data().resource("quotes").action("report").id(id).params().get(),
queryFn: () =>
dataSource.custom({
url: `${dataSource.getApiUrl()}/quotes/${id}/report`,
method: "get",
headers: {
responseType: "arraybuffer",
},
}),
enabled: !!id,
select: (data) => new Uint8Array(data),
...params,
}),
};
};

View File

@ -1,4 +1,4 @@
import { ColumnDef, Table as ReactTable, flexRender } from "@tanstack/react-table";
import { ColumnDef, Table as ReactTable, Row, flexRender } from "@tanstack/react-table";
import { PropsWithChildren, ReactNode } from "react";
import {
@ -45,6 +45,7 @@ export type DataTableProps<TData> = PropsWithChildren<{
footerClassName?: string;
rowClassName?: string;
cellClassName?: string;
onRowClick?: (row: Row<TData>) => void;
}>;
export function DataTable<TData>({
@ -60,6 +61,7 @@ export function DataTable<TData>({
footerClassName,
rowClassName,
cellClassName,
onRowClick,
}: DataTableProps<TData>) {
const headerVisible = headerOptions?.visible;
@ -104,6 +106,8 @@ export function DataTable<TData>({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
onClick={() => (onRowClick ? onRowClick(row) : null)}
tabIndex={0}
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(row.getIsSelected() ? "bg-accent" : "", rowClassName)}

View File

@ -0,0 +1,156 @@
import { Button } from "@/ui";
import { useResizeObserver } from "@wojtekmaj/react-hooks";
import type { PDFDocumentProxy } from "pdfjs-dist";
import printJS from "print-js";
import { useCallback, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
const options = {
cMapUrl: "/cmaps/",
standardFontDataUrl: "/standard_fonts/",
};
const maxWidth = 800;
const resizeObserverOptions = {};
export interface PDFViewerProps {
file?: Uint8Array;
className?: string;
}
export const PDFViewer = ({ file, className }: PDFViewerProps): JSX.Element => {
const [eventResize, setEventResize] = useState(false);
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [width, setWidth] = useState(undefined);
//const parentRef = useRef(null);
//const canvasRef = useRef(null);
const [containerRef, setContainerRef] = useState<HTMLElement | null>(null);
const [containerWidth, setContainerWidth] = useState<number>();
const onResize = useCallback<ResizeObserverCallback>((entries) => {
const [entry] = entries;
if (entry) {
setContainerWidth(entry.contentRect.width);
}
}, []);
useResizeObserver(containerRef, resizeObserverOptions, onResize);
/*const hidePageCanvas = useCallback(() => {
const canvas = containerRef?.current?.querySelector("canvas");
if (canvas) canvas.style.visibility = "hidden";
}, [containerRef]);
const showPageCanvas = useCallback(() => {
const canvas = containerRef?.current?.querySelector("canvas");
if (canvas) canvas.style.visibility = "visible";
}, [containerRef]);
const onPageLoadSuccess = useCallback(() => {
hidePageCanvas();
}, [hidePageCanvas]);
const onPageRenderSuccess = useCallback(() => {
showPageCanvas();
}, [showPageCanvas]);
const onPageRenderError = useCallback(() => {
showPageCanvas();
}, [showPageCanvas]);
*/
function onDocumentLoadSuccess({ numPages: nextNumPages }: PDFDocumentProxy): void {
setNumPages(nextNumPages);
}
/*function onDocumentLoadSuccess({ numPages: nextNumPages }: PDFDocumentProxy): void {
setPageNumber(1);
setNumPages(nextNumPages);
}*/
const changePage = (offset: number) =>
setPageNumber((prevPage) =>
offset > 0 ? Math.min(prevPage + offset, numPages) : Math.max(prevPage + offset, 1)
);
const goToNextPage = () => changePage(1);
const goToPrevPage = () => changePage(-1);
const goToFirstPage = () => setPageNumber(1);
const goToLastPage = () => setPageNumber(numPages);
/*useEffect(() => {
if (numPages > 0 || eventResize) {
const parentWidth = parentRef.current ? parentRef.current.offsetWidth : 0;
const canvasWidth = canvasRef.current ? canvasRef.current.width : 0;
console.log("Document => ", parentWidth);
console.log("Canvas => ", canvasWidth);
setWidth(parentWidth);
setEventResize(false);
}
}, [eventResize, numPages]);**/
//file={`data:application/pdf;base64,$(pdfBase64String)`}
return (
<div className='flex flex-col' ref={setContainerRef}>
<Document
options={options}
file={file}
onLoadSuccess={onDocumentLoadSuccess}
className={className}
>
{Array.from(new Array(numPages), (_el, index) => (
<Page
canvasBackground={"white"}
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth ? Math.min(containerWidth, maxWidth) : maxWidth}
//onLoadSuccess={onPageLoadSuccess}
//onRenderSuccess={onPageRenderSuccess}
//onRenderError={onPageRenderError}
/>
))}
</Document>
<p className='text-center'>
Página {pageNumber} de {numPages}
</p>
<div>
<Button size={"icon"} variant='link' onClick={goToPrevPage}>
Prev
</Button>
<Button size={"icon"} variant='link' onClick={goToNextPage}>
Next
</Button>
<Button
size={"icon"}
variant='link'
onClick={(e) => {
e.preventDefault();
printJS({
printable: file,
type: "pdf",
showModal: false,
modalMessage: "Cargando...",
});
}}
>
Imprimir react
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './PDFViewer';

View File

@ -10,6 +10,7 @@ export * from "./Forms";
export * from "./Layout";
export * from "./LoadingIndicator";
export * from "./LoadingOverlay";
export * from "./PDFViewer";
export * from "./ProtectedRoute";
//export * from "./SorteableDataTable";
export * from "./TailwindIndicator";

View File

@ -1,6 +1,7 @@
import { IListResponse_DTO, INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@shared/contexts";
import {
ICreateOneDataProviderParams,
ICustomDataProviderParam,
IDataSource,
IFilterItemDataProviderParam,
IGetListDataProviderParams,
@ -18,6 +19,10 @@ export const createAxiosDataProvider = (
): IDataSource => ({
name: () => "AxiosDataProvider",
getApiUrl: () => {
return apiUrl;
},
getList: async <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
const { resource, quickSearchTerm, pagination, filters, sort } = params;
@ -111,6 +116,56 @@ export const createAxiosDataProvider = (
return;
},
custom: async <R>(params: ICustomDataProviderParam): Promise<R> => {
const { url, method, headers, payload } = params;
const requestUrl = `${url}?`;
/*if (sort) {
const generatedSort = extractSortParams(sort);
if (generatedSort) {
const { _sort, _order } = generatedSort;
const sortQuery = {
_sort: _sort.join(","),
_order: _order.join(","),
};
requestUrl = `${requestUrl}&${queryString.stringify(sortQuery)}`;
}
}
if (filters) {
const filterQuery = extractFilterParams(filters);
requestUrl = `${requestUrl}&${queryString.stringify(filterQuery)}`;
}*/
/*if (query) {
requestUrl = `${requestUrl}&${queryString.stringify(query)}`;
}*/
if (headers) {
httpClient.defaults.headers = {
...httpClient.defaults.headers,
...headers,
};
}
let customResponse;
switch (method) {
case "put":
case "post":
case "patch":
customResponse = await httpClient[method]<R>(url, payload);
break;
case "remove":
customResponse = await httpClient.delete<R>(url);
break;
default:
customResponse = await httpClient.get<R>(requestUrl);
break;
}
return customResponse.data;
},
/*getMany: async ({ resource }) => {
const { body } = await httpClient.request({
url: `${apiUrl}/${resource}`,

View File

@ -1,4 +1,5 @@
import { IListResponse_DTO } from "@shared/contexts";
import { AxiosHeaderValue } from "axios";
export interface IPaginationDataProviderParam {
pageIndex: number;
@ -51,11 +52,14 @@ export interface IRemoveOneDataProviderParams {
id: string;
}
/*export interface ICustomDataProviderParam {
resource: string;
method: string;
params: any;
}*/
export interface ICustomDataProviderParam {
url: string;
method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch";
headers?: {
[key: string]: AxiosHeaderValue;
};
payload?: unknown;
}
export interface IDataSource {
name: () => string;
@ -66,9 +70,9 @@ export interface IDataSource {
updateOne: <P, R>(params: IUpdateOneDataProviderParams<P>) => Promise<R>;
removeOne: (params: IRemoveOneDataProviderParams) => Promise<void>;
//custom: <R>(params: ICustomDataProviderParam) => Promise<R>;
custom: <R>(params: ICustomDataProviderParam) => Promise<R>;
//getApiUrl: () => string;
getApiUrl: () => string;
//create: () => any;
//createMany: () => any;

View File

@ -1,5 +1,6 @@
// export * from './useApiUrl';
// export * from './useCreateMany';
export * from "./useCustom";
export * from "./useList";
export * from "./useMany";
export * from "./useOne";

View File

@ -0,0 +1,14 @@
import { UseQueryOptions, UseQueryResult, keepPreviousData, useQuery } from "@tanstack/react-query";
import { TDataSourceError, TDataSourceRecord } from "./types";
export function useCustom<
TQueryFnData extends TDataSourceRecord = TDataSourceRecord,
TError = TDataSourceError,
TData extends TDataSourceRecord = TQueryFnData
>(options: UseQueryOptions<TQueryFnData, TError, TData>): UseQueryResult<TData, TError> {
return useQuery<TQueryFnData, TError, TData>({
placeholderData: keepPreviousData,
...options,
});
}

View File

@ -1,7 +1,7 @@
type BaseKey = string | number;
type ParametrizedDataActions = "list" | "infinite";
type IdRequiredDataActions = "one";
type IdRequiredDataActions = "one" | "report";
type IdsRequiredDataActions = "many";
type DataMutationActions =
| "custom"
@ -100,7 +100,7 @@ class DataResourceKeyBuilder extends BaseKeyBuilder {
action(
actionType: ParametrizedDataActions | IdRequiredDataActions | IdsRequiredDataActions
): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
if (actionType === "one") {
if (["one", "report"].includes(actionType)) {
return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
}
if (actionType === "many") {

View File

@ -1,7 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { resolve } from "node:path";
const require = createRequire(import.meta.url);
const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json"));
const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
//const require = createRequire(import.meta.url);
@ -17,17 +23,28 @@ const standardFontsDir = normalizePath(
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: cMapsDir,
dest: "",
},
],
}),
],
css: { postcss: "./postcss.config.js" },
resolve: {
alias: [
{
find: "@",
replacement: resolve(__dirname, "./src"),
replacement: path.resolve(__dirname, "./src"),
},
{
find: "@shared",
replacement: resolve(__dirname, "../shared/lib/"),
replacement: path.resolve(__dirname, "../shared/lib/"),
},
],
},

View File

@ -62,7 +62,7 @@ export abstract class ExpressController implements IController {
}
public downloadPDF(pdfBuffer: Buffer, filename: string) {
return this._download(pdfBuffer, "application/pdf", `${filename}.pdf`);
return this._download(pdfBuffer, "application/pdf", `${filename}`);
}
public clientError(message?: string) {
@ -125,7 +125,7 @@ export abstract class ExpressController implements IController {
this.res.set({
"Content-Type": contentType,
"Content-Disposition": `attachment; filename=${filename}`,
"Content-Length": buffer.length,
//"Content-Length": buffer.length,
});
return this.res.send(buffer);

View File

@ -55,7 +55,7 @@ export class ReportQuoteController extends ExpressController {
const quote = <Quote>result.object;
return this.downloadPDF(await this.reporter.toPDF(quote, this.context), "prueba.pdf");
return this.downloadPDF(await this.reporter.toPDF(quote, this.context), "quote.pdf");
} catch (e: unknown) {
return this.fail(e as IServerError);
}

View File

@ -0,0 +1 @@
export type IReportQuote_Response_DTO = Uint8Array;

View File

@ -0,0 +1 @@
export * from "./IReportQuote_Response.dto";

View File

@ -1,4 +1,5 @@
export * from "./CreateQuote.dto";
export * from "./GetQuote.dto";
export * from "./ListQuotes.dto";
export * from "./ReportQuote.dto";
export * from "./UpdateQuote.dto";