This commit is contained in:
David Arranz 2024-07-09 18:21:12 +02:00
parent ae0dabfca3
commit 981d70cffe
85 changed files with 1810 additions and 1203 deletions

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
$schema: "https://json.schemastore.org/eslintrc",
root: true, root: true,
env: { browser: true, es2020: true }, env: { browser: true, es2020: true },
extends: [ extends: [
@ -12,5 +13,6 @@ module.exports = {
rules: { rules: {
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
}, },
}; };

View File

@ -6,9 +6,8 @@ import { Suspense } from "react";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { Routes } from "./Routes"; import { Routes } from "./Routes";
import { LoadingOverlay, TailwindIndicator } from "./components"; import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosDataProvider } from "./lib/axios"; import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
import { createAxiosAuthActions } from "./lib/axios/createAxiosAuthActions"; import { DataSourceProvider } from "./lib/hooks";
import { DataSourceProvider } from "./lib/hooks/useDataSource/DataSourceContext";
function App() { function App() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -28,6 +27,7 @@ function App() {
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Suspense fallback={<LoadingOverlay />}> <Suspense fallback={<LoadingOverlay />}>
<Routes /> <Routes />
<ToastContainer /> <ToastContainer />
</Suspense> </Suspense>
</TooltipProvider> </TooltipProvider>

View File

@ -86,7 +86,7 @@ export const Routes = () => {
element: <QuoteCreate />, element: <QuoteCreate />,
}, },
{ {
path: "edit", path: "edit/:id",
element: <QuoteEdit />, element: <QuoteEdit />,
}, },
], ],
@ -109,11 +109,7 @@ export const Routes = () => {
}, },
{ {
path: "/logout", path: "/logout",
element: ( element: <LogoutPage />,
<ProtectedRoute>
<LogoutPage />
</ProtectedRoute>
),
}, },
]; ];

View File

@ -1,54 +0,0 @@
import { IDataSource } from '@/lib/hooks/useDataSource/DataSource';
export type SuccessNotificationResponse = {
message: string;
description?: string;
};
export type PermissionResponse = unknown;
export type IdentityResponse = unknown;
export type CatalogActionCheckResponse = {
authenticated: boolean;
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type CatalogActionOnErrorResponse = {
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type CatalogActionResponse = {
success: boolean;
redirectTo?: string;
error?: Error;
[key: string]: unknown;
successNotification?: SuccessNotificationResponse;
};
export interface ICatalogActions {
listCatalog: (
dataSource: IDataSource,
pagination: {
pageIndex: number;
pageSize: number;
} = {
pageIndex: INITIAL_PAGE_INDEX,
pageSize: INITIAL_PAGE_SIZE,
},
quickSearchTerm: string = "",
): Promise<IListResponse_DTO<IListCustomerInvoices_Response_DTO>> => {
return dataProvider.getList({
resource: "invoices",
quickSearchTerm,
pagination,
});
};
}
}

View File

@ -24,7 +24,7 @@ export const CatalogDataTable = () => {
searchTerm: globalFilter, searchTerm: globalFilter,
}); });
const columns = useMemo<ColumnDef<IListArticles_Response_DTO, any>[]>( const columns = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(
() => [ () => [
{ {
id: "id" as const, id: "id" as const,
@ -65,7 +65,7 @@ export const CatalogDataTable = () => {
id: "retail_price" as const, id: "retail_price" as const,
accessorKey: "retail_price", accessorKey: "retail_price",
header: () => <div className='text-right'>{t("catalog.list.columns.retail_price")}</div>, header: () => <div className='text-right'>{t("catalog.list.columns.retail_price")}</div>,
cell: ({ row }: { row: Row<any> }) => { cell: ({ row }: { row: Row<IListArticles_Response_DTO> }) => {
const price = MoneyValue.create(row.original.retail_price).object; const price = MoneyValue.create(row.original.retail_price).object;
return <div className='text-right'>{price.toFormat()}</div>; return <div className='text-right'>{price.toFormat()}</div>;
}, },
@ -113,10 +113,8 @@ export const CatalogDataTable = () => {
} }
return ( return (
<> <DataTable table={table} paginationOptions={{ visible: true }} title='Catálogo'>
<DataTable table={table} paginationOptions={{ visible: true }}> <DataTableToolbar table={table} />
<DataTableToolbar table={table} /> </DataTable>
</DataTable>
</>
); );
}; };

View File

@ -32,6 +32,7 @@ import {
PaginationItem, PaginationItem,
Progress, Progress,
Separator, Separator,
Skeleton,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@ -44,20 +45,24 @@ import {
TabsTrigger, TabsTrigger,
} from "@/ui"; } from "@/ui";
import { t } from "i18next"; import { t } from "i18next";
import { useNavigate } from "react-router-dom";
export const DashboardPage = () => { export const DashboardPage = () => {
const { data, status } = useGetIdentity(); const navigate = useNavigate();
const { data: userIdentity, status } = useGetIdentity();
return ( return (
<Layout> <Layout>
<LayoutHeader /> <LayoutHeader />
<LayoutContent> <LayoutContent>
{status === "success" && ( {status === "success" ? (
<div className='flex items-center'> <div className='flex items-center'>
<h1 className='text-lg font-semibold md:text-2xl'>{`${t("dashboard.welcome")}, ${ <h1 className='text-lg font-semibold md:text-2xl'>{`${t("dashboard.welcome")}, ${
data?.name userIdentity?.name
}`}</h1> }`}</h1>
</div> </div>
) : (
<Skeleton className='w-[100px] h-[20px] rounded-full' />
)} )}
<div className='grid items-start flex-1 gap-4 p-4 sm:px-6 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'> <div className='grid items-start flex-1 gap-4 p-4 sm:px-6 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'>
@ -72,7 +77,9 @@ export const DashboardPage = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardFooter> <CardFooter>
<Button>Crear nueva cotización</Button> <Button onClick={() => navigate("/quotes/add")}>
{t("quotes.create.title")}
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
<Card x-chunk='dashboard-05-chunk-1'> <Card x-chunk='dashboard-05-chunk-1'>

View File

@ -0,0 +1,134 @@
import { Card, CardContent } from "@/ui";
import { DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
import { useCatalogList } from "@/app/catalog/hooks";
import { DataTable } from "@/components";
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table";
import { t } from "i18next";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
export const CatalogPickerDataTable = ({ onClick }: { onClick: (data: unknown) => void }) => {
const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext();
const { data, isPending, isError, error } = useCatalogList({
pagination: {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
},
searchTerm: globalFilter,
});
const columns = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(() => {
return [
{
id: "description" as const,
accessorKey: "description",
enableResizing: false,
header: () => null,
cell: ({
row,
renderValue,
}: {
row: Row<IListArticles_Response_DTO>;
renderValue: () => any;
}) => {
const price = MoneyValue.create(row.original.retail_price).object;
const points = row.original.points;
return (
<button
key={row.id}
className={cn("rounded-lg border p-3 transition-all hover:bg-accent w-full", "")}
onClick={
(event) => {
console.log("hola");
event.preventDefault();
onClick && onClick(row.original);
}
/*setMail({
...mail,
selected: item.id,
})*/
}
>
<div className='flex flex-row justify-between w-full space-x-6'>
<div className='text-left grow line-clamp-2 text-muted-foreground hover:text-foreground'>
{renderValue()}
</div>
<div className='text-right'>
<dl className='flex flex-row justify-end space-x-1'>
<dt className='text-xs font-medium text-accent-foreground/75'>
{t("catalog.list.columns.points")}:
</dt>
<dd className='text-xs font-semibold'>{points}</dd>
</dl>
<dl className='flex flex-row justify-end space-x-1'>
<dt className='text-xs font-medium text-accent-foreground/75'>
{t("catalog.list.columns.retail_price")}:
</dt>
<dt className='text-xs font-semibold'>{price.toFormat()}</dt>
</dl>
</div>
</div>
</button>
);
},
},
];
}, []);
const { table } = useDataTable({
data: data?.items ?? [],
columns: columns,
pageCount: data?.total_pages ?? -1,
});
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>
);
}
if (data?.total_items === 0 && !isFiltered) {
return (
<SimpleEmptyState
subtitle='Empieza cargando los artículos del catálogo'
buttonText=''
onButtonClick={() => navigate("/catalog/add")}
/>
);
}
return (
<DataTable
table={table}
headerOptions={{ visible: false }}
paginationOptions={{ visible: true }}
title='Catálogo'
rowClassName='border-b-0'
cellClassName='px-0'
>
<DataTableToolbar fullWidthFilter={true} table={table} />
</DataTable>
);
};

View File

@ -125,6 +125,7 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(); const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]); const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
@ -145,8 +146,8 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getRowId: (originalRow: unknown) => originalRow?.id, getRowId: (originalRow: unknown) => originalRow?.id,
debugHeaders: true, debugHeaders: false,
debugColumns: true, debugColumns: false,
meta: { meta: {
insertItem: (rowIndex: number, data: object = {}) => { insertItem: (rowIndex: number, data: object = {}) => {
actions.insert(rowIndex, data, { shouldFocus: true }); actions.insert(rowIndex, data, { shouldFocus: true });
@ -263,16 +264,43 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
const hadleNewItem = useCallback(() => { const hadleNewItem = useCallback(() => {
actions.append([ actions.append([
{ {
description: "a", quantity: { amount: "123" },
description: "aaaa",
retail_price: {
amount: "10000",
precision: 4,
currency: "EUR",
},
discount: {
amount: 35,
},
}, },
{ {
description: "b", quantity: {
amount: "2",
},
description: "bbbb",
discount: {
amount: 55,
},
}, },
{ {
description: "c", quantity: {
amount: "3",
},
description: "cccc",
discount: {
amount: 75,
},
}, },
{ {
description: "d", quantity: {
amount: "4",
},
description: "dddd",
discount: {
amount: 10,
},
}, },
]); ]);
}, [actions]); }, [actions]);

View File

@ -1,20 +1,21 @@
import { import {
ButtonGroup,
CancelButton,
FormGroup,
FormMoneyField, FormMoneyField,
FormPercentageField,
FormQuantityField,
FormTextAreaField, FormTextAreaField,
FormTextField,
SubmitButton,
} from "@/components"; } from "@/components";
import { Input } from "@/ui"; import { DataTableProvider } from "@/lib/hooks";
import { t } from "i18next"; import { cn } from "@/lib/utils";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui";
import { Quantity } from "@shared/contexts";
import { useCallback, useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form"; import { useFieldArray, useFormContext } from "react-hook-form";
import { useDetailColumns } from "../../hooks"; import { useDetailColumns } from "../../hooks";
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
import { SortableDataTable } from "../SortableDataTable"; import { SortableDataTable } from "../SortableDataTable";
export const QuoteDetailsCardEditor = () => { export const QuoteDetailsCardEditor = () => {
const { control, register, formState } = useFormContext(); const { control, register, watch, getValues, setValue } = useFormContext();
const { fields, ...fieldActions } = useFieldArray({ const { fields, ...fieldActions } = useFieldArray({
control, control,
@ -39,27 +40,16 @@ export const QuoteDetailsCardEditor = () => {
accessorKey: "quantity", accessorKey: "quantity",
header: "quantity", header: "quantity",
size: 5, size: 5,
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index } }) => {
return ( return <FormQuantityField {...register(`items.${index}.quantity`)} />;
<FormTextField
type='number'
control={control}
{...register(`items.${index}.quantity`)}
/>
);
}, },
}, },
{ {
id: "description" as const, id: "description" as const,
accessorKey: "description", accessorKey: "description",
cell: ({ row: { index }, column: { id } }) => { header: "description",
return ( cell: ({ row: { index } }) => {
<FormTextAreaField return <FormTextAreaField autoSize {...register(`items.${index}.description`)} />;
autoSize
control={control}
{...register(`items.${index}.description`)}
/>
);
}, },
}, },
@ -69,7 +59,7 @@ export const QuoteDetailsCardEditor = () => {
header: "retail_price", header: "retail_price",
size: 10, size: 10,
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <Input key={id} {...register(`items.${index}.retail_price`)} />; return <FormMoneyField {...register(`items.${index}.retail_price`)} />;
}, },
}, },
{ {
@ -78,7 +68,7 @@ export const QuoteDetailsCardEditor = () => {
header: "price", header: "price",
size: 10, size: 10,
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormMoneyField control={control} {...register(`items.${index}.price`)} />; return <FormMoneyField {...register(`items.${index}.price`)} />;
}, },
}, },
{ {
@ -87,7 +77,7 @@ export const QuoteDetailsCardEditor = () => {
header: "discount", header: "discount",
size: 5, size: 5,
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormMoneyField control={control} {...register(`items.${index}.discount`)} />; return <FormPercentageField {...register(`items.${index}.discount`)} />;
}, },
}, },
{ {
@ -96,7 +86,7 @@ export const QuoteDetailsCardEditor = () => {
header: "total", header: "total",
size: 10, size: 10,
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormMoneyField control={control} {...register(`items.${index}.total`)} />; return <FormMoneyField {...register(`items.${index}.total`)} />;
}, },
}, },
], ],
@ -135,24 +125,54 @@ export const QuoteDetailsCardEditor = () => {
} }
); );
const handleInsertArticle = useCallback(
(newArticle) => {
console.log(newArticle);
fieldActions.append({
...newArticle,
quantity: {
amount: 1,
precision: Quantity.DEFAULT_PRECISION,
},
});
},
[fieldActions]
);
const [isCollapsed, setIsCollapsed] = useState(false);
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
return ( return (
<FormGroup <ResizablePanelGroup
title={t("quotes.create.tabs.items.title")} direction='horizontal'
description={t("quotes.create.tabs.items.desc")} autoSaveId='uecko.quotes.details_layout'
actions={ className='items-stretch h-full'
<ButtonGroup className='md:hidden'>
<CancelButton onClick={() => null} size='sm' />
<SubmitButton
disabled={!formState.isDirty || formState.isSubmitting || formState.isLoading}
label='Guardar'
size='sm'
/>
</ButtonGroup>
}
> >
<div className='gap-0'> <ResizablePanel
defaultSize={defaultLayout[0]}
collapsedSize={navCollapsedSize}
collapsible={true}
minSize={50}
maxSize={90}
onCollapse={() => {
setIsCollapsed(true);
}}
onExpand={() => {
setIsCollapsed(false);
}}
className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")}
>
<SortableDataTable actions={fieldActions} columns={columns} data={fields} /> <SortableDataTable actions={fieldActions} columns={columns} data={fields} />
</div> </ResizablePanel>
</FormGroup> <ResizableHandle withHandle className='mx-3' />
<ResizablePanel defaultSize={defaultLayout[1]} minSize={10}>
<DataTableProvider syncWithLocation={false}>
<CatalogPickerDataTable onClick={handleInsertArticle} />
</DataTableProvider>
</ResizablePanel>
</ResizablePanelGroup>
); );
}; };

View File

@ -9,38 +9,21 @@ import { t } from "i18next";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { SubmitButton } from "@/components"; import { SubmitButton } from "@/components";
import { useWarnAboutChange } from "@/lib/hooks";
import { Button, Form } from "@/ui"; import { Button, Form } from "@/ui";
import { ICreateQuote_Request_DTO } from "@shared/contexts";
import { useEffect } from "react";
import { FieldErrors, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; import { FieldErrors, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuotes } from "./hooks"; import { useQuotes } from "./hooks";
type QuoteDataForm = { interface QuoteDataForm extends ICreateQuote_Request_DTO {}
id: string;
status: string;
date: string;
reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
items: any[];
};
/*type QuoteCreateProps = {
isOverModal?: boolean;
};*/
export const QuoteCreate = () => { export const QuoteCreate = () => {
//const [loading, setLoading] = useState(false);
//const { data: userIdentity } = useGetIdentity();
//console.log(userIdentity);
const navigate = useNavigate(); const navigate = useNavigate();
const { useMutation } = useQuotes(); const { setWarnWhen } = useWarnAboutChange();
const { mutate } = useMutation(); const { useCreate } = useQuotes();
const { mutate } = useCreate();
const form = useForm<QuoteDataForm>({ const form = useForm<QuoteDataForm>({
defaultValues: { defaultValues: {
@ -50,11 +33,23 @@ export const QuoteCreate = () => {
}, },
}); });
const { watch, handleSubmit } = form;
useEffect(() => {
const subscription = watch((values: any, { type }: { type?: any }) => {
if (type === "change") {
// Hay cambios en el formulario
//setWarnWhen(true);
}
});
return () => subscription.unsubscribe();
}, [watch, setWarnWhen]);
const onSubmit: SubmitHandler<QuoteDataForm> = async (formData) => { const onSubmit: SubmitHandler<QuoteDataForm> = async (formData) => {
alert(JSON.stringify(formData)); console.log(JSON.stringify(formData));
try { try {
//setLoading(true); setWarnWhen(false);
mutate(formData, { mutate(formData, {
onSuccess: (data) => { onSuccess: (data) => {
navigate(`/quotes/edit/${data.id}`, { relative: "path", replace: true }); navigate(`/quotes/edit/${data.id}`, { relative: "path", replace: true });
@ -73,10 +68,15 @@ export const QuoteCreate = () => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit, onErrors)}> <form onSubmit={handleSubmit(onSubmit, onErrors)}>
<div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'> <div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'>
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<Button variant='outline' size='icon' className='h-7 w-7'> <Button
variant='outline'
size='icon'
className='h-7 w-7'
onClick={() => navigate("/quotes")}
>
<ChevronLeft className='w-4 h-4' /> <ChevronLeft className='w-4 h-4' />
<span className='sr-only'>{t("quotes.common.back")}</span> <span className='sr-only'>{t("quotes.common.back")}</span>
</Button> </Button>
@ -85,7 +85,7 @@ export const QuoteCreate = () => {
</h1> </h1>
</div> </div>
<div className='grid max-w-lg gap-6'> <div className='grid w-6/12 gap-6 mx-auto'>
<FormTextField <FormTextField
className='row-span-2' className='row-span-2'
name='reference' name='reference'
@ -112,12 +112,17 @@ export const QuoteCreate = () => {
description={t("quotes.create.form_fields.customer_information.desc")} description={t("quotes.create.form_fields.customer_information.desc")}
placeholder={t("quotes.create.form_fields.customer_information.placeholder")} placeholder={t("quotes.create.form_fields.customer_information.placeholder")}
/> />
</div>
<div className='flex items-center justify-start gap-2'> <div className='flex items-center justify-around gap-2'>
<BackHistoryButton size='sm' label={t("quotes.create.buttons.discard")} url='/quotes' /> <BackHistoryButton
size='sm'
variant={"outline"}
label={t("quotes.create.buttons.discard")}
url='/quotes'
/>
<SubmitButton size='sm' label={t("common.continue")}></SubmitButton> <SubmitButton size='sm' label={t("common.continue")}></SubmitButton>
</div>
</div> </div>
</div> </div>
</form> </form>

View File

@ -1,21 +1,23 @@
import { ChevronLeft } from "lucide-react"; import { FormMoneyField, LoadingOverlay, SubmitButton } from "@/components";
import { calculateItemTotals } from "@/lib/calc";
import { SubmitButton } from "@/components";
import { useGetIdentity } from "@/lib/hooks"; import { useGetIdentity } from "@/lib/hooks";
import { useUrlId } from "@/lib/hooks/useUrlId";
import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui"; import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui";
import { IUpdateQuote_Request_DTO, MoneyValue } from "@shared/contexts";
import { t } from "i18next"; import { t } from "i18next";
import { useState } from "react"; import { ChevronLeftIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { import { QuoteDetailsCardEditor, QuoteGeneralCardEditor } from "./components/editors";
QuoteDetailsCardEditor,
QuoteDocumentsCardEditor,
QuoteGeneralCardEditor,
} from "./components/editors";
import { useQuotes } from "./hooks"; import { useQuotes } from "./hooks";
type QuoteDataForm = { // simple typesafe helperfunction
id: string; type EndsWith<T, b extends string> = T extends `${infer f}${b}` ? T : never;
status: string; const endsWith = <T extends string, b extends string>(str: T, prefix: b): str is EndsWith<T, b> =>
str.endsWith(prefix);
interface QuoteDataForm extends IUpdateQuote_Request_DTO {
/*status: string;
date: string; date: string;
reference: string; reference: string;
customer_information: string; customer_information: string;
@ -24,23 +26,31 @@ type QuoteDataForm = {
payment_method: string; payment_method: string;
notes: string; notes: string;
validity: string; validity: string;
items: any[]; discount: IPercentage;
};
type QuoteCreateProps = { subtotal: IMoney;
isOverModal?: boolean;
};
export const QuoteEdit = ({ isOverModal }: QuoteCreateProps) => { items: {
quantity: IQuantity;
description: string;
retail_price: IMoney;
price: IMoney;
discount: IPercentage;
total: IMoney;
}[];*/
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const QuoteEdit = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const quoteId = useUrlId();
const { data: userIdentity } = useGetIdentity(); const { data: userIdentity } = useGetIdentity();
console.log(userIdentity);
const { useQuery, useMutation } = useQuotes(); const { useOne, useUpdate } = useQuotes();
const { data } = useQuery; const { data, status } = useOne(quoteId);
const { mutate } = useMutation; const { mutate } = useUpdate(quoteId);
const form = useForm<QuoteDataForm>({ const form = useForm<QuoteDataForm>({
mode: "onBlur", mode: "onBlur",
@ -54,53 +64,119 @@ export const QuoteEdit = ({ isOverModal }: QuoteCreateProps) => {
payment_method: "", payment_method: "",
notes: "", notes: "",
validity: "", validity: "",
subtotal: "",
items: [], items: [],
}, },
}); });
const onSubmit: SubmitHandler<QuoteDataForm> = async (data) => { const onSubmit: SubmitHandler<QuoteDataForm> = async (data) => {
alert(JSON.stringify(data)); console.debug(JSON.stringify(data));
try { try {
setLoading(true); setLoading(true);
data.currency_code = "EUR"; // Transformación del form -> typo de request
data.lang_code = String(userIdentity?.language); mutate(data, {
onError: (error) => {
mutate(data); alert(error);
},
//onSettled: () => {},
onSuccess: () => {
alert("guardado");
},
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const { watch, getValues, setValue } = form;
useEffect(() => {
const { unsubscribe } = watch((_, { name, type }) => {
const value = getValues();
console.debug({ name, type });
if (name) {
if (name === "items") {
const { items } = value;
let quoteSubtotal = MoneyValue.create().object;
// Recálculo líneas
items.map((item, index) => {
const itemTotals = calculateItemTotals(item);
quoteSubtotal = quoteSubtotal.add(itemTotals.total);
setValue(`items.${index}.price`, itemTotals.price.toObject());
setValue(`items.${index}.total`, itemTotals.total.toObject());
});
console.log(quoteSubtotal.toFormat());
// Recálculo completo
setValue("subtotal", quoteSubtotal.toObject());
}
if (
endsWith(name, "quantity") ||
endsWith(name, "retail_price") ||
endsWith(name, "discount")
) {
const { items } = value;
const [, indexString, fieldName] = String(name).split(".");
const index = parseInt(indexString);
const itemTotals = calculateItemTotals(items[index]);
setValue(`items.${index}.price`, itemTotals.price.toObject());
setValue(`items.${index}.total`, itemTotals.total.toObject());
// Recálculo completo
}
}
});
return () => unsubscribe();
}, [watch, getValues, setValue]);
if (status !== "success") {
return <LoadingOverlay />;
}
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'> <div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'>
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<Button variant='outline' size='icon' className='h-7 w-7'> <Button variant='outline' size='icon' className='h-7 w-7'>
<ChevronLeft className='w-4 h-4' /> <ChevronLeftIcon className='w-4 h-4' />
<span className='sr-only'>{t("quotes.common.back")}</span> <span className='sr-only'>{t("quotes.common.back")}</span>
</Button> </Button>
<h1 className='flex-1 text-xl font-semibold tracking-tight shrink-0 whitespace-nowrap sm:grow-0'> <h1 className='flex-1 text-xl font-semibold tracking-tight shrink-0 whitespace-nowrap sm:grow-0'>
{t("quotes.create.title")} {t("quotes.edit.title")}
</h1> </h1>
<Badge variant='default' className='ml-auto sm:ml-0'> <Badge variant='default' className='ml-auto sm:ml-0'>
{t("quotes.status.draft")} {data.status}
</Badge> </Badge>
<div className='items-center hidden gap-2 md:ml-auto md:flex'> <div className='items-center hidden gap-2 md:ml-auto md:flex'>
<Button variant='outline' size='sm'> <Button variant='outline' size='sm'>
{t("quotes.create.buttons.discard")} {t("common.cancel")}
</Button> </Button>
<SubmitButton variant={form.formState.isDirty ? "default" : "outline"} size='sm'> <SubmitButton variant={form.formState.isDirty ? "default" : "outline"} size='sm'>
{t("quotes.create.buttons.save_quote")} {t("common.save")}
</SubmitButton> </SubmitButton>
</div> </div>
</div> </div>
<Tabs defaultValue='general' className='space-y-4'>
<FormMoneyField
label={"subtotal"}
disabled={form.formState.disabled}
{...form.register("subtotal")}
/>
<Tabs defaultValue='items' className='space-y-4'>
<TabsList> <TabsList>
<TabsTrigger value='general'>{t("quotes.create.tabs.general")}</TabsTrigger> <TabsTrigger value='general'>{t("quotes.create.tabs.general")}</TabsTrigger>
<TabsTrigger value='items'>{t("quotes.create.tabs.items")}</TabsTrigger> <TabsTrigger value='items'>{t("quotes.create.tabs.items")}</TabsTrigger>
<TabsTrigger value='documents'>{t("quotes.create.tabs.documents")}</TabsTrigger>
<TabsTrigger value='history'>{t("quotes.create.tabs.history")}</TabsTrigger> <TabsTrigger value='history'>{t("quotes.create.tabs.history")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='general'> <TabsContent value='general'>
@ -110,9 +186,6 @@ export const QuoteEdit = ({ isOverModal }: QuoteCreateProps) => {
<QuoteDetailsCardEditor /> <QuoteDetailsCardEditor />
</TabsContent> </TabsContent>
<TabsContent value='documents'>
<QuoteDocumentsCardEditor />
</TabsContent>
<TabsContent value='history'></TabsContent> <TabsContent value='history'></TabsContent>
</Tabs> </Tabs>
<div className='flex items-center justify-center gap-2 md:hidden'> <div className='flex items-center justify-center gap-2 md:hidden'>

View File

@ -6,6 +6,8 @@ import {
ICreateQuote_Request_DTO, ICreateQuote_Request_DTO,
ICreateQuote_Response_DTO, ICreateQuote_Response_DTO,
IGetQuote_Response_DTO, IGetQuote_Response_DTO,
IUpdateQuote_Request_DTO,
IUpdateQuote_Response_DTO,
UniqueID, UniqueID,
} from "@shared/contexts"; } from "@shared/contexts";
@ -14,22 +16,23 @@ export type UseQuotesGetParamsType = {
queryOptions?: Record<string, unknown>; queryOptions?: Record<string, unknown>;
}; };
export const useQuotes = (params?: UseQuotesGetParamsType) => { export const useQuotes = () => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey(); const keys = useQueryKey();
return { return {
useQuery: () => useOne: (id?: string, params?: UseQuotesGetParamsType) =>
useOne<IGetQuote_Response_DTO>({ useOne<IGetQuote_Response_DTO>({
queryKey: keys().data().resource("quotes").action("one").id("").params().get(), queryKey: keys().data().resource("quotes").action("one").id("").params().get(),
queryFn: () => queryFn: () =>
dataSource.getOne({ dataSource.getOne({
resource: "quotes", resource: "quotes",
id: "", id: String(id),
}), }),
enabled: !!id,
...params, ...params,
}), }),
useMutation: () => useCreate: () =>
useSave<ICreateQuote_Response_DTO, TDataSourceError, ICreateQuote_Request_DTO>({ useSave<ICreateQuote_Response_DTO, TDataSourceError, ICreateQuote_Request_DTO>({
mutationKey: keys().data().resource("quotes").action("one").id("").params().get(), mutationKey: keys().data().resource("quotes").action("one").id("").params().get(),
mutationFn: (data) => { mutationFn: (data) => {
@ -50,5 +53,17 @@ export const useQuotes = (params?: UseQuotesGetParamsType) => {
}); });
}, },
}), }),
useUpdate: (id: string) =>
useSave<IUpdateQuote_Response_DTO, TDataSourceError, IUpdateQuote_Request_DTO>({
mutationKey: keys().data().resource("quotes").action("one").id(id).params().get(),
mutationFn: (data) => {
return dataSource.updateOne({
resource: "quotes",
id,
data,
});
},
}),
}; };
}; };

View File

@ -1,51 +0,0 @@
import { useOne } from '@/lib/hooks/useDataSource';
import { IDataSource } from '@/lib/hooks/useDataSource/DataSource';
export type SuccessNotificationResponse = {
message: string;
description?: string;
};
export type PermissionResponse = unknown;
export type IdentityResponse = unknown;
export type CatalogActionCheckResponse = {
authenticated: boolean;
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type CatalogActionOnErrorResponse = {
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type CatalogActionResponse = {
success: boolean;
redirectTo?: string;
error?: Error;
[key: string]: unknown;
successNotification?: SuccessNotificationResponse;
};
export interface ISettingsActions {
getSettings: (
dataSource: IDataSource,
): Promise<IGetProfileResponse_DTO> => {
return useOne(
)
return dataProvider.getList({
resource: "invoices",
quickSearchTerm,
pagination,
});
};
}
}

View File

@ -1,6 +1,14 @@
import { ColumnDef, Table as ReactTable, flexRender } from "@tanstack/react-table"; import { ColumnDef, Table as ReactTable, flexRender } from "@tanstack/react-table";
import { PropsWithChildren, ReactNode } from "react";
import { import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Separator,
Table, Table,
TableBody, TableBody,
TableCaption, TableCaption,
@ -8,11 +16,9 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/ui/table"; } from "@/ui";
import { PropsWithChildren, ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardFooter, CardHeader } from "@/ui";
import { DataTableColumnHeader } from "./DataTableColumnHeader"; import { DataTableColumnHeader } from "./DataTableColumnHeader";
import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination"; import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination";
@ -23,38 +29,58 @@ export type DataTablePaginationOptionsProps<TData> = Pick<
"visible" "visible"
>; >;
export type DataTableHeaderOptionsProps = {
visible: boolean;
};
export type DataTableProps<TData> = PropsWithChildren<{ export type DataTableProps<TData> = PropsWithChildren<{
table: ReactTable<TData>; table: ReactTable<TData>;
title?: ReactNode;
description?: ReactNode;
caption?: ReactNode; caption?: ReactNode;
paginationOptions?: DataTablePaginationOptionsProps<TData>; paginationOptions?: DataTablePaginationOptionsProps<TData>;
headerOptions?: DataTableHeaderOptionsProps;
className?: string; className?: string;
rowClassName?: string;
cellClassName?: string;
}>; }>;
export function DataTable<TData>({ export function DataTable<TData>({
table, table,
title,
description,
caption, caption,
paginationOptions, paginationOptions,
headerOptions = { visible: true },
children, children,
className, className,
...props rowClassName,
cellClassName,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const headerVisible = headerOptions?.visible;
return ( return (
<> <Card className={className}>
<Card> {(title || description) && (
<CardHeader className='pb-0'> <CardHeader className='pb-0'>
<CardDescription <CardTitle>{title}</CardTitle>
className={cn("w-full space-y-2.5 overflow-auto mt-7", className)} <CardDescription>{description}</CardDescription>
{...props}
>
{children}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='pt-6'> )}
<Table> <CardContent className='pt-6'>
{typeof caption !== "undefined" && <TableCaption>{caption}</TableCaption>} {children && (
<>
<div className='flex space-x-2'>{children}</div>
<Separator className='my-4' />
</>
)}
<Table>
{typeof caption !== "undefined" && <TableCaption>{caption}</TableCaption>}
{headerVisible && table.getHeaderGroups().length && (
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id} className={rowClassName}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead <TableHead
@ -69,35 +95,42 @@ export function DataTable<TData>({
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> )}
{table.getRowModel().rows?.length ? ( <TableBody>
table.getRowModel().rows.map((row) => ( {table.getRowModel().rows?.length ? (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => ( <TableRow
<TableCell key={cell.id}> key={row.id}
{flexRender(cell.column.columnDef.cell, cell.getContext())} data-state={row.getIsSelected() && "selected"}
</TableCell> className={rowClassName}
))} >
</TableRow> {row.getVisibleCells().map((cell) => (
)) <TableCell key={cell.id} className={cellClassName}>
) : ( {flexRender(cell.column.columnDef.cell, cell.getContext())}
<TableRow> </TableCell>
<TableCell colSpan={table.getAllColumns.length} className='h-24 text-center'> ))}
No hay datos para mostrar
</TableCell>
</TableRow> </TableRow>
)} ))
</TableBody> ) : (
</Table> <TableRow className={rowClassName}>
</CardContent> <TableCell
<CardFooter> className={cn("h-24 text-center", cellClassName)}
<DataTablePagination colSpan={table.getAllColumns.length}
className='flex-1' >
visible={paginationOptions?.visible} No hay datos para mostrar
table={table} </TableCell>
/> </TableRow>
</CardFooter> )}
</Card> </TableBody>
</> </Table>
</CardContent>
<CardFooter>
<DataTablePagination
className='flex-1'
visible={paginationOptions?.visible}
table={table}
/>
</CardFooter>
</Card>
); );
} }

View File

@ -28,7 +28,7 @@ export function DataTableColumnHeader<TData, TValue>({
<> <>
<div <div
className={cn( className={cn(
"data-[state=open]:bg-accent font-semiboldw text-muted-foreground uppercase", "data-[state=open]:bg-accent font-bold text-muted-foreground uppercase text-xs tracking-wide",
className className
)} )}
> >

View File

@ -68,6 +68,7 @@ export function DataTablePagination<TData>({
</div> </div>
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-2'>
<Button <Button
type='button'
variant='outline' variant='outline'
className='hidden w-8 h-8 p-0 lg:flex' className='hidden w-8 h-8 p-0 lg:flex'
onClick={() => table.setPageIndex(INITIAL_PAGE_INDEX)} onClick={() => table.setPageIndex(INITIAL_PAGE_INDEX)}
@ -77,6 +78,7 @@ export function DataTablePagination<TData>({
<ChevronsLeftIcon className='w-4 h-4' /> <ChevronsLeftIcon className='w-4 h-4' />
</Button> </Button>
<Button <Button
type='button'
variant='outline' variant='outline'
className='w-8 h-8 p-0' className='w-8 h-8 p-0'
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
@ -86,6 +88,7 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='w-4 h-4' /> <ChevronLeftIcon className='w-4 h-4' />
</Button> </Button>
<Button <Button
type='button'
variant='outline' variant='outline'
className='w-8 h-8 p-0' className='w-8 h-8 p-0'
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
@ -95,6 +98,7 @@ export function DataTablePagination<TData>({
<ChevronRightIcon className='w-4 h-4' /> <ChevronRightIcon className='w-4 h-4' />
</Button> </Button>
<Button <Button
type='button'
variant='outline' variant='outline'
className='hidden w-8 h-8 p-0 lg:flex' className='hidden w-8 h-8 p-0 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() + 1)} onClick={() => table.setPageIndex(table.getPageCount() + 1)}

View File

@ -1,24 +1,21 @@
import { Checkbox } from "@/ui"; import { Checkbox } from "@/ui";
import { DataTableColumnProps } from "./DataTable"; import { DataTableColumnProps } from "./DataTable";
export function getDataTableSelectionColumn< export function getDataTableSelectionColumn<TData, TError>(): DataTableColumnProps<TData, TError> {
TData,
TError,
>(): DataTableColumnProps<TData, TError> {
return { return {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
id="select-all" id='select-all'
checked={ checked={
table.getIsAllPageRowsSelected() || table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
(table.getIsSomePageRowsSelected() && "indeterminate")
} }
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Seleccionar todo" aria-label='Seleccionar todo'
className="translate-y-[2px]" className='translate-y-[2px]'
/> />
), ),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
cell: ({ row, table }) => ( cell: ({ row, table }) => (
<Checkbox <Checkbox
id={`select-row-${row.id}`} id={`select-row-${row.id}`}
@ -26,8 +23,8 @@ export function getDataTableSelectionColumn<
onCheckedChange={(value) => { onCheckedChange={(value) => {
row.toggleSelected(!!value); row.toggleSelected(!!value);
}} }}
aria-label="Seleccionar file" aria-label='Seleccionar file'
className="translate-y-[2px]" className='translate-y-[2px]'
/> />
), ),
enableSorting: false, enableSorting: false,

View File

@ -11,10 +11,12 @@ import { DataTableColumnOptions } from "./DataTableColumnOptions";
interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> { interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>; table: Table<TData>;
filterFields?: DataTableFilterField<TData>[]; filterFields?: DataTableFilterField<TData>[];
fullWidthFilter?: boolean;
} }
export function DataTableToolbar<TData>({ export function DataTableToolbar<TData>({
table, table,
fullWidthFilter,
className, className,
children, children,
...props ...props
@ -35,7 +37,7 @@ export function DataTableToolbar<TData>({
placeholder={t("common.filter_placeholder")} placeholder={t("common.filter_placeholder")}
value={globalFilter} value={globalFilter}
onChange={(event) => setGlobalFilter(String(event.target.value))} onChange={(event) => setGlobalFilter(String(event.target.value))}
className='w-3/12 h-8 lg:w-6/12' className={cn("h-8", fullWidthFilter ? "w-full" : "w-3/12 lg:w-6/12")}
/> />
{isFiltered && ( {isFiltered && (

View File

@ -1,126 +1,136 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import { FormControl, FormDescription, FormMessage, Input } from "@/ui";
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@/ui";
import { createElement } from "react"; import { IMoney } from "@/lib/types";
import { MoneyValue } from "@shared/contexts";
import { createElement, forwardRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { FormLabel } from "./FormLabel";
import { FormTextFieldProps } from "./FormTextField"; import { FormTextFieldProps } from "./FormTextField";
type FormMoneyFieldProps = Omit<FormTextFieldProps, "type">; type FormMoneyFieldProps = Omit<FormTextFieldProps, "type"> & {
defaultValue?: any;
};
// Spanish currency config export const FormMoneyField = forwardRef<
const moneyFormatter = Intl.NumberFormat("es-ES", { HTMLDivElement,
currency: "EUR", React.HTMLAttributes<HTMLDivElement> & FormMoneyFieldProps
currencyDisplay: "symbol", >((props, ref) => {
currencySign: "standard", const {
style: "currency", label,
minimumFractionDigits: 2, placeholder,
maximumFractionDigits: 2, hint,
}); description,
required,
export function FormMoneyField({ className,
label, leadIcon,
placeholder, trailIcon,
hint, button,
description, disabled,
required, name,
className, defaultValue,
leadIcon, } = props;
trailIcon,
button,
disabled,
errors,
name,
control,
}: FormMoneyFieldProps) {
/*const initialValue = props.form.getValues()[props.name]
? moneyFormatter.format(props.form.getValues()[props.name])
: "";
const [value, setValue] = useReducer((_: any, next: string) => {
const digits = next.replace(/\D/g, "");
return moneyFormatter.format(Number(digits) / 100);
}, initialValue);
function handleChange(realChangeFn: Function, formattedValue: string) {
const digits = formattedValue.replace(/\D/g, "");
const realValue = Number(digits) / 100;
realChangeFn(realValue);
}*/
//const error = Boolean(errors && errors[name]); //const error = Boolean(errors && errors[name]);
const { control } = useFormContext();
const [precision, setPrecision] = useState<number>(MoneyValue.DEFAULT_PRECISION);
const [currencyCode, setCurrencyCode] = useState<string>(MoneyValue.DEFAULT_CURRENCY_CODE);
const transform = { const transform = {
input: (value: any) => input: (value: IMoney) => {
isNaN(value) || value === 0 ? "" : moneyFormatter.format(value), const moneyOrError = MoneyValue.create(value);
if (moneyOrError.isFailure) {
throw moneyOrError.error;
}
const moneyValue = moneyOrError.object;
setPrecision(moneyValue.getPrecision());
setCurrencyCode(moneyValue.getCurrency().code);
return moneyValue.toFormat();
},
output: (event: React.ChangeEvent<HTMLInputElement>) => { output: (event: React.ChangeEvent<HTMLInputElement>) => {
const output = parseInt(event.target.value, 10); const output = parseFloat(event.target.value);
return isNaN(output) ? 0 : output;
const moneyOrError = MoneyValue.create({
amount: output * Math.pow(10, precision),
precision,
currencyCode,
});
if (moneyOrError.isFailure) {
throw moneyOrError.error;
}
return moneyOrError.object.toObject();
}, },
}; };
return ( return (
<FormField <Controller
defaultValue={defaultValue}
control={control} control={control}
name={name} name={name}
rules={{ required }} rules={{ required }}
disabled={disabled} disabled={disabled}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => { render={({ field, fieldState, formState }) => {
return ( return (
<FormItem className={cn(className, "space-y-3")}> <input
{...field}
placeholder='number'
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
);
return (
<div className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} />} {label && <FormLabel label={label} hint={hint} />}
<div className={cn(button ? "flex" : null)}> <div className={cn(button ? "flex" : null)}>
<div <div
className={cn( className={cn(
leadIcon leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : ""
? "relative flex items-stretch flex-grow focus-within:z-10"
: "",
)} )}
> >
{leadIcon && ( {leadIcon && (
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
{createElement( {createElement(
leadIcon, leadIcon,
{ {
className: "h-5 w-5 text-muted-foreground", className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true, "aria-hidden": true,
}, },
null, null
)} )}
</div> </div>
)} )}
<FormControl <FormControl
className={cn( className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
"block",
leadIcon ? "pl-10" : "",
trailIcon ? "pr-10" : "",
)}
> >
<Input <Input
type="text"
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} className={cn(
fieldState.error ? "border-destructive focus-visible:ring-destructive" : ""
)}
{...field} {...field}
onChange={(e) => field.onChange(transform.output(e))} onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)} value={transform.input(field.value)}
/> />
</FormControl> </FormControl>
{trailIcon && ( {trailIcon && (
<div className="absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none"> <div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'>
{createElement( {createElement(
trailIcon, trailIcon,
{ {
className: "h-5 w-5 text-muted-foreground", className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true, "aria-hidden": true,
}, },
null, null
)} )}
</div> </div>
)} )}
@ -129,9 +139,9 @@ export function FormMoneyField({
</div> </div>
{description && <FormDescription>{description}</FormDescription>} {description && <FormDescription>{description}</FormDescription>}
<FormMessage /> <FormMessage />
</FormItem> </div>
); );
}} }}
/> />
); );
} });

View File

@ -0,0 +1,164 @@
import { cn } from "@/lib/utils";
import { FormControl, FormDescription, FormItem, InputProps } from "@/ui";
import { Percentage, PercentageObject } from "@shared/contexts";
import { createElement, forwardRef, useState } from "react";
import {
Controller,
FieldPath,
FieldValues,
UseControllerProps,
useFormContext,
} from "react-hook-form";
import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
export type FormPercentageFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
button?: (props?: React.PropsWithChildren) => React.ReactNode;
defaultValue?: any;
} & InputProps &
FormInputProps &
Partial<FormLabelProps> &
FormInputWithIconProps &
UseControllerProps<TFieldValues, TName>;
export const FormPercentageField = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormPercentageFieldProps
>((props, ref) => {
const {
name,
label,
hint,
placeholder,
description,
required,
className,
leadIcon,
trailIcon,
button,
defaultValue,
} = props;
const { control } = useFormContext();
const [precision, setPrecision] = useState<number>(Percentage.DEFAULT_PRECISION);
const transform = {
input: (value: PercentageObject) => {
const percentageOrError = Percentage.create(value);
if (percentageOrError.isFailure) {
throw percentageOrError.error;
}
const percentageValue = percentageOrError.object;
setPrecision(percentageValue.getPrecision());
return percentageValue.toString();
},
output: (event: React.ChangeEvent<HTMLInputElement>): PercentageObject => {
const value = parseFloat(event.target.value);
const output = !isNaN(value) ? value : 0;
const percentageOrError = Percentage.create({
amount: output * Math.pow(10, precision),
precision,
});
if (percentageOrError.isFailure) {
throw percentageOrError.error;
}
return percentageOrError.object.toObject();
},
};
return (
<Controller
defaultValue={defaultValue}
control={control}
name={name}
rules={{
required,
min: Percentage.MIN_VALUE,
max: Percentage.MAX_VALUE,
}}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => {
return (
<input
type='number'
{...field}
className='text-right'
placeholder='number'
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
);
return (
<FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={required} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(
leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : ""
)}
>
{leadIcon && (
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
{createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
<FormControl
className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
>
<input
type='number'
placeholder={placeholder}
className={cn(
fieldState.error ? "border-destructive focus-visible:ring-destructive" : ""
)}
{...field}
onInput={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
</FormControl>
{trailIcon && (
<div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'>
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage />
</FormItem>
);
}}
/>
);
});

View File

@ -0,0 +1,162 @@
import { cn } from "@/lib/utils";
import { FormControl, FormDescription, FormItem, InputProps } from "@/ui";
import { Quantity, QuantityObject } from "@shared/contexts";
import { createElement, forwardRef, useState } from "react";
import {
Controller,
FieldPath,
FieldValues,
UseControllerProps,
useFormContext,
} from "react-hook-form";
import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
export type FormQuantityFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
button?: (props?: React.PropsWithChildren) => React.ReactNode;
defaultValue?: any;
} & InputProps &
FormInputProps &
Partial<FormLabelProps> &
FormInputWithIconProps &
UseControllerProps<TFieldValues, TName>;
export const FormQuantityField = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormQuantityFieldProps
>((props, ref) => {
const {
name,
label,
hint,
placeholder,
description,
required,
className,
leadIcon,
trailIcon,
button,
defaultValue,
} = props;
const { control } = useFormContext();
const [precision, setPrecision] = useState<number>(Quantity.DEFAULT_PRECISION);
const transform = {
input: (value: QuantityObject) => {
const quantityOrError = Quantity.create(value);
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
const quantityValue = quantityOrError.object;
setPrecision(quantityValue.getPrecision());
return quantityValue.toString();
},
output: (event: React.ChangeEvent<HTMLInputElement>): QuantityObject => {
const value = parseFloat(event.target.value);
const output = !isNaN(value) ? value : 0;
const quantityOrError = Quantity.create({
amount: output * Math.pow(10, precision),
precision,
});
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
return quantityOrError.object.toObject();
},
};
return (
<Controller
defaultValue={defaultValue}
control={control}
name={name}
rules={{
required,
}}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => {
return (
<input
type='number'
{...field}
className='text-right'
placeholder={placeholder}
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
);
return (
<FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={required} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(
leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : ""
)}
>
{leadIcon && (
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
{createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
<FormControl
className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
>
<input
type='number'
placeholder={placeholder}
className={cn(
fieldState.error ? "border-destructive focus-visible:ring-destructive" : ""
)}
{...field}
onInput={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
</FormControl>
{trailIcon && (
<div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'>
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage />
</FormItem>
);
}}
/>
);
});

View File

@ -3,5 +3,7 @@ export * from "./FormErrorMessage";
export * from "./FormGroup"; export * from "./FormGroup";
export * from "./FormLabel"; export * from "./FormLabel";
export * from "./FormMoneyField"; export * from "./FormMoneyField";
export * from "./FormPercentageField";
export * from "./FormQuantityField";
export * from "./FormTextAreaField"; export * from "./FormTextAreaField";
export * from "./FormTextField"; export * from "./FormTextField";

View File

@ -1,7 +1,11 @@
import { UnsavedChangesNotifier, UnsavedWarnProvider } from "@/lib/hooks";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
export const Layout = ({ children }: PropsWithChildren) => ( export const Layout = ({ children }: PropsWithChildren) => (
<div className='flex flex-col w-full min-h-screen'>{children}</div> <UnsavedWarnProvider>
<div className='flex flex-col w-full min-h-screen'>{children}</div>
<UnsavedChangesNotifier />
</UnsavedWarnProvider>
); );
Layout.displayName = "Layout"; Layout.displayName = "Layout";

View File

@ -10,7 +10,6 @@ export type LoadingIndicatorProps = {
subtitle?: string; subtitle?: string;
}; };
// eslint-disable-next-line no-unused-vars
export const LoadingIndicator = ({ export const LoadingIndicator = ({
active = true, active = true,
look = "dark", look = "dark",

View File

@ -10,6 +10,13 @@ type ProctectRouteProps = {
export const ProtectedRoute = ({ children }: ProctectRouteProps) => { export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
const { isPending, isSuccess, data: { authenticated, redirectTo } = {} } = useIsLoggedIn(); const { isPending, isSuccess, data: { authenticated, redirectTo } = {} } = useIsLoggedIn();
console.debug("ProtectedRouter", {
isPending,
isSuccess,
authenticated,
redirectTo,
});
if (isPending) { if (isPending) {
return <LoadingOverlay />; return <LoadingOverlay />;
} }

View File

@ -1,207 +0,0 @@
// https://dev.to/mperon/axios-error-handling-like-a-boss-333d
import axios, { AxiosError } from "axios";
export interface ValidationErrors {
[field: string]:
| string
| string[]
| boolean
| { key: string; message: string };
}
export interface IHttpError extends Record<string, any> {
message: string;
statusCode: number;
errors?: ValidationErrors;
}
export class HttpError extends Error {
constructor(message?: string) {
super(message); // 'Error' breaks prototype chain here
this.name = "HttpError";
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
}
}
export const httpErrorHandler = (error: any) => {
if (error === null) throw new Error("Unrecoverable error!! Error is null!");
if (axios.isAxiosError(error)) {
//here we have a type guard check, error inside this if will be treated as AxiosError
const response = error?.response;
const request = error?.request;
const config = error?.config; //here we have access the config used to make the api call (we can make a retry using this conf)
if (error.code === "ERR_NETWORK") {
console.log("connection problems..");
} else if (error.code === "ERR_CANCELED") {
console.log("connection canceled..");
}
if (response) {
//The request was made and the server responded with a status code that falls out of the range of 2xx the http status code mentioned above
const statusCode = response?.status;
if (statusCode === 404) {
console.log(
"The requested resource does not exist or has been deleted",
);
} else if (statusCode === 401) {
console.log("Please login to access this resource");
//redirect user to login
}
} else if (request) {
//The request was made but no response was received, `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in Node.js
}
}
//Something happened in setting up the request and triggered an Error
console.log(error.message);
};
interface HttpData {
code: string;
description?: string;
status: number;
}
// this is all errrors allowed to receive
type THttpError = Error | AxiosError | null;
// object that can be passed to our registy
interface ErrorHandlerObject {
after?(error?: THttpError, options?: ErrorHandlerObject): void;
before?(error?: THttpError, options?: ErrorHandlerObject): void;
message?: string;
notify?: any; //QNotifyOptions;
}
//signature of error function that can be passed to ours registry
type ErrorHandlerFunction = (
error?: THttpError,
) => ErrorHandlerObject | boolean | undefined;
//type that our registry accepts
type ErrorHandler = ErrorHandlerFunction | ErrorHandlerObject | string;
//interface for register many handlers once (object where key will be presented as search key for error handling
interface ErrorHandlerMany {
[key: string]: ErrorHandler;
}
// type guard to identify that is an ErrorHandlerObject
function isErrorHandlerObject(value: any): value is ErrorHandlerObject {
if (typeof value === "object") {
return ["message", "after", "before", "notify"].some((k) => k in value);
}
return false;
}
class ErrorHandlerRegistry {
private handlers = new Map<string, ErrorHandler>();
private parent: ErrorHandlerRegistry | null = null;
constructor(
parent: ErrorHandlerRegistry = undefined,
input?: ErrorHandlerMany,
) {
if (typeof parent !== "undefined") this.parent = parent;
if (typeof input !== "undefined") this.registerMany(input);
}
// allow to register an handler
register(key: string, handler: ErrorHandler) {
this.handlers.set(key, handler);
return this;
}
// unregister a handler
unregister(key: string) {
this.handlers.delete(key);
return this;
}
// search a valid handler by key
find(seek: string): ErrorHandler | undefined {
const handler = this.handlers.get(seek);
if (handler) return handler;
return this.parent?.find(seek);
}
// pass an object and register all keys/value pairs as handler.
registerMany(input: ErrorHandlerMany) {
for (const [key, value] of Object.entries(input)) {
this.register(key, value);
}
return this;
}
// handle error seeking for key
handleError(
this: ErrorHandlerRegistry,
seek: (string | undefined)[] | string,
error: THttpError,
): boolean {
if (Array.isArray(seek)) {
return seek.some((key) => {
if (key !== undefined) return this.handleError(String(key), error);
});
}
const handler = this.find(String(seek));
if (!handler) {
return false;
} else if (typeof handler === "string") {
return this.handleErrorObject(error, { message: handler });
} else if (typeof handler === "function") {
const result = handler(error);
if (isErrorHandlerObject(result))
return this.handleErrorObject(error, result);
return !!result;
} else if (isErrorHandlerObject(handler)) {
return this.handleErrorObject(error, handler);
}
return false;
}
// if the error is an ErrorHandlerObject, handle here
handleErrorObject(error: THttpError, options: ErrorHandlerObject = {}) {
options?.before?.(error, options);
showToastError(options.message ?? "Unknown Error!!", options, "error");
return true;
}
// this is the function that will be registered in interceptor.
resposeErrorHandler(
this: ErrorHandlerRegistry,
error: THttpError,
direct?: boolean,
) {
if (error === null)
throw new Error("Unrecoverrable error!! Error is null!");
if (axios.isAxiosError(error)) {
const response = error?.response;
const config = error?.config;
const data = response?.data as HttpData;
if (!direct && config?.raw) throw error;
const seekers = [
data?.code,
error.code,
error?.name,
String(data?.status),
String(response?.status),
];
const result = this.handleError(seekers, error);
if (!result) {
if (data?.code && data?.description) {
return this.handleErrorObject(error, {
message: data?.description,
});
}
}
} else if (error instanceof Error) {
return this.handleError(error.name, error);
}
//if nothings works, throw away
throw error;
}
}
// create ours globalHandlers object
const globalHandlers = new ErrorHandlerRegistry();

View File

@ -8,7 +8,6 @@ export const createAxiosAuthActions = (
httpClient = createAxiosInstance() httpClient = createAxiosInstance()
): IAuthActions => ({ ): IAuthActions => ({
login: async ({ email, password }: ILogin_DTO) => { login: async ({ email, password }: ILogin_DTO) => {
// eslint-disable-next-line no-useless-catch
try { try {
const result = await httpClient.request<ILogin_Response_DTO>({ const result = await httpClient.request<ILogin_Response_DTO>({
url: `${apiUrl}/auth/login`, url: `${apiUrl}/auth/login`,
@ -55,16 +54,14 @@ export const createAxiosAuthActions = (
? { ? {
authenticated: true, authenticated: true,
} }
: { authenticated: false, redirectTo: "/login" } : {
authenticated: false,
redirectTo: "/login",
}
); );
}, },
getIdentity: async () => { getIdentity: async () => {
const errorResult = {
message: "Identification failed",
name: "Invalid profile or identification",
};
try { try {
const result = await httpClient.request<IIdentity_Response_DTO>({ const result = await httpClient.request<IIdentity_Response_DTO>({
url: `${apiUrl}/auth/identity`, url: `${apiUrl}/auth/identity`,
@ -78,14 +75,13 @@ export const createAxiosAuthActions = (
secureLocalStorage.setItem("uecko.profile", data); secureLocalStorage.setItem("uecko.profile", data);
return Promise.resolve(data); return Promise.resolve(data);
} }
return Promise.reject(errorResult); return Promise.resolve(null);
} catch (error) { } catch (error) {
return Promise.reject(errorResult); return Promise.resolve(null);
} }
}, },
onError: (error: any) => { onError: (error: any) => {
console.error(error);
secureLocalStorage.clear(); secureLocalStorage.clear();
return Promise.resolve({ return Promise.resolve({
error, error,

View File

@ -1,65 +0,0 @@
import { IListResponse_DTO } from "@shared/contexts";
import {
IDataSource,
IGetListDataProviderParams,
IGetOneDataProviderParams,
IPaginationDataProviderParam,
} from "../hooks/useDataSource/DataSource";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
export const createJSONDataProvider = (jsonData: unknown[]): IDataSource => ({
name: () => "JSONDataProvider",
getList: <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
const { resource, quickSearchTerm, pagination, filters, sort } = params;
const queryPagination = extractPaginationParams(pagination);
const items = jsonData.slice(
queryPagination.page * queryPagination.limit,
queryPagination.page * queryPagination.limit + queryPagination.limit
);
const totalItems = jsonData.length;
const totalPages = Math.ceil(totalItems / queryPagination.limit);
const response: IListResponse_DTO<R> = {
page: queryPagination.page,
per_page: queryPagination.limit,
total_pages: totalPages,
total_items: totalItems,
items,
};
return Promise.resolve(response);
},
getOne: async <R>(params: IGetOneDataProviderParams): Promise<R> => {
/*const { resource, id } = params;
const response = await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "GET",
});
return response.data;*/
},
createOne: <P>(params: ICreateOneDataProviderParams<P>) => {
throw Error;
},
updateOne: <P>(params: IUpdateOneDataProviderParams<P>) => {
throw Error;
},
removeOne: (params: IRemoveOneDataProviderParams) => {
throw Error;
},
});
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
return {
page: pageIndex,
limit: pageSize,
};
};

View File

@ -1,3 +1,2 @@
export * from "./HttpError";
export * from "./createAxiosAuthActions"; export * from "./createAxiosAuthActions";
export * from "./createAxiosDataProvider"; export * from "./createAxiosDataProvider";

View File

@ -59,7 +59,7 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
break; break;
case 401: case 401:
console.error("UnAuthorized"); console.error("UnAuthorized");
//return (window.location.href = "/logout"); return (window.location.href = "/logout");
break; break;
case 403: case 403:
console.error("Forbidden"); console.error("Forbidden");

59
client/src/lib/calc.ts Normal file
View File

@ -0,0 +1,59 @@
import { MoneyValue, Percentage, Quantity } from "@shared/contexts";
import { IMoney, IPercentage, IQuantity } from "./types";
export const calculateItemTotals = (item: {
quantity?: IQuantity;
retail_price?: IMoney;
discount?: IPercentage;
}): {
quantity: Quantity;
retailPrice: MoneyValue;
price: MoneyValue;
discount: Percentage;
total: MoneyValue;
} => {
const { quantity: quantityValue, retail_price: retailPriceValue, discount: discountValue } = item;
console.log({
quantityValue,
retailPriceValue,
discountValue,
});
const quantityOrError = Quantity.create(quantityValue);
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
const quantity = quantityOrError.object;
const retailPriceOrError = MoneyValue.create(retailPriceValue);
if (retailPriceOrError.isFailure) {
throw retailPriceOrError.error;
}
const retailPrice = retailPriceOrError.object;
const discountOrError = Percentage.create(discountValue);
if (discountOrError.isFailure) {
throw discountOrError.error;
}
const discount = discountOrError.object;
const price = retailPrice.multiply(quantity.toNumber());
const total = price.subtract(price.percentage(discount.toNumber()));
return {
quantity,
retailPrice,
price,
discount,
total,
};
/*return {
quantity: quantity.toObject(),
retail_price: retailPrice.toObject(),
price: price.toObject(),
discount: discount.toObject(),
total: total.toObject(),
};*/
};

View File

@ -13,7 +13,9 @@ export * from "./useUrlId";
export * from "./useAuth"; export * from "./useAuth";
export * from "./useCustomDialog"; export * from "./useCustomDialog";
export * from "./useDataSource";
export * from "./useDataTable"; export * from "./useDataTable";
export * from "./useLocalization"; export * from "./useLocalization";
export * from "./usePagination"; export * from "./usePagination";
export * from "./useTheme"; export * from "./useTheme";
export * from "./useUnsavedChangesNotifier";

View File

@ -7,7 +7,7 @@ export type SuccessNotificationResponse = {
export type PermissionResponse = unknown; export type PermissionResponse = unknown;
export type IdentityResponse = IIdentity_Response_DTO; export type IdentityResponse = IIdentity_Response_DTO | null;
export type AuthActionCheckResponse = { export type AuthActionCheckResponse = {
authenticated: boolean; authenticated: boolean;

View File

@ -28,6 +28,7 @@ export const AuthProvider = ({
}; };
const handleCheck = async () => { const handleCheck = async () => {
console.trace("check");
try { try {
return Promise.resolve(authActions.check?.()); return Promise.resolve(authActions.check?.());
} catch (error) { } catch (error) {

View File

@ -9,6 +9,7 @@ export const useIsLoggedIn = (queryOptions?: UseQueryOptions<AuthActionCheckResp
const result = useQuery<AuthActionCheckResponse>({ const result = useQuery<AuthActionCheckResponse>({
queryKey: keys().auth().action("check").get(), queryKey: keys().auth().action("check").get(),
queryFn: check, queryFn: check,
retry: false,
...queryOptions, ...queryOptions,
}); });

View File

@ -1,19 +0,0 @@
import { useEffect, useState } from "react";
import { useMatches } from "react-router";
export const useBreadcrumbs = (): any[] => {
const [crumbs, setCrumbs] = useState<any[]>([]);
let matches = useMatches();
useEffect(() => {
const _crumbs = matches
// @ts-ignore
.filter((match) => Boolean(match.handle?.crumb))
// @ts-ignore
.map((match) => match.handle?.crumb(match.data));
setCrumbs(_crumbs);
}, matches);
return crumbs;
};

View File

@ -1,37 +1,30 @@
import { import { QueryFunction, QueryKey, UseQueryResult, useQuery } from "@tanstack/react-query";
QueryFunction,
QueryKey,
UseQueryResult,
useQuery,
} from '@tanstack/react-query';
export interface IUseManyQueryOptions< export interface IUseManyQueryOptions<
TUseManyQueryData = unknown, TUseManyQueryData = unknown,
TUseManyQueryError = unknown // eslint-disable-next-line @typescript-eslint/no-unused-vars
TUseManyQueryError = unknown
> { > {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<TUseManyQueryData, QueryKey>; queryFn: QueryFunction<TUseManyQueryData, QueryKey>;
enabled?: boolean; enabled?: boolean;
select?: (data: TUseManyQueryData) => TUseManyQueryData; select?: (data: TUseManyQueryData) => TUseManyQueryData;
queryOptions?: any; queryOptions?: any;
} }
export function useMany<TUseManyQueryData, TUseManyQueryError>( export function useMany<TUseManyQueryData, TUseManyQueryError>(
options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError> options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError>
): UseQueryResult<TUseManyQueryData, TUseManyQueryError> { ): UseQueryResult<TUseManyQueryData, TUseManyQueryError> {
const { queryKey, queryFn, enabled, select, queryOptions } = options; const { queryKey, queryFn, enabled, select, queryOptions } = options;
const queryResponse = useQuery<TUseManyQueryData, TUseManyQueryError>({
queryKey,
queryFn,
keepPreviousData: true,
...queryOptions,
enabled,
select,
}); const queryResponse = useQuery<TUseManyQueryData, TUseManyQueryError>({
queryKey,
queryFn,
keepPreviousData: true,
...queryOptions,
enabled,
select,
});
return queryResponse; return queryResponse;
} }

View File

@ -1,30 +1,29 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
export interface IUseRemoveMutationOptions< export interface IUseRemoveMutationOptions<
TUseRemoveMutationData, TUseRemoveMutationData,
TUseRemoveMutationError, // eslint-disable-next-line @typescript-eslint/no-unused-vars
TUseRemoveMutationVariables TUseRemoveMutationError,
TUseRemoveMutationVariables
> { > {
mutationFn: ( mutationFn: (variables: TUseRemoveMutationVariables) => Promise<TUseRemoveMutationData>;
variables: TUseRemoveMutationVariables,
) => Promise<TUseRemoveMutationData>;
} }
export function useRemove< export function useRemove<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
>(
options: IUseRemoveMutationOptions<
TUseRemoveMutationData, TUseRemoveMutationData,
TUseRemoveMutationError, TUseRemoveMutationError,
TUseRemoveMutationVariables>(options: IUseRemoveMutationOptions< TUseRemoveMutationVariables
TUseRemoveMutationData, >
TUseRemoveMutationError, ) {
TUseRemoveMutationVariables const { mutationFn, ...params } = options;
>) {
const { mutationFn, ...params } = options;
return useMutation< return useMutation<TUseRemoveMutationData, TUseRemoveMutationError, TUseRemoveMutationVariables>({
TUseRemoveMutationData, mutationFn,
TUseRemoveMutationError, ...params,
TUseRemoveMutationVariables>({ });
mutationFn,
...params
});
} }

View File

@ -1,12 +1,21 @@
import { PaginationState, usePaginationParams } from "@/lib/hooks"; import { PaginationState } from "@/lib/hooks";
import { SortingState } from "@tanstack/react-table"; import { SortingState } from "@tanstack/react-table";
import { PropsWithChildren, createContext, useCallback, useMemo, useState } from "react"; import {
Dispatch,
PropsWithChildren,
SetStateAction,
createContext,
useCallback,
useMemo,
useState,
} from "react";
import { useSyncedPagination } from "./useSyncedPagination";
export interface IDataTableContextState { export interface IDataTableContextState {
pagination: PaginationState; pagination: PaginationState;
setPagination: (newPagination: PaginationState) => void; setPagination: (newPagination: PaginationState) => void;
sorting: []; sorting: SortingState;
setSorting: () => void; setSorting: Dispatch<SetStateAction<SortingState>>;
globalFilter: string; globalFilter: string;
setGlobalFilter: (newGlobalFilter: string) => void; setGlobalFilter: (newGlobalFilter: string) => void;
resetGlobalFilter: () => void; resetGlobalFilter: () => void;
@ -16,12 +25,14 @@ export interface IDataTableContextState {
export const DataTableContext = createContext<IDataTableContextState | null>(null); export const DataTableContext = createContext<IDataTableContextState | null>(null);
export const DataTableProvider = ({ export const DataTableProvider = ({
syncWithLocation = true,
initialGlobalFilter = "", initialGlobalFilter = "",
children, children,
}: PropsWithChildren<{ }: PropsWithChildren<{
syncWithLocation?: boolean;
initialGlobalFilter?: string; initialGlobalFilter?: string;
}>) => { }>) => {
const [pagination, setPagination] = usePaginationParams(); const [pagination, setPagination] = useSyncedPagination(syncWithLocation);
const [globalFilter, setGlobalFilter] = useState<string>(initialGlobalFilter); const [globalFilter, setGlobalFilter] = useState<string>(initialGlobalFilter);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);

View File

@ -1,192 +0,0 @@
import { differenceWith, isEmpty, isEqual, reverse, unionWith } from 'lodash';
export const parseQueryString = (queryString: Record<string, string>) => {
// pagination
const pageIndex = parseInt(queryString.page ?? '0', 0) - 1;
const pageSize = Math.min(parseInt(queryString.limit ?? '10', 10), 100);
const pagination =
pageIndex >= 0 && pageSize > 0 ? { pageIndex, pageSize } : undefined;
// pagination
/*let pagination = undefined;
if (page !== undefined && limit !== undefined) {
let parsedPage = toSafeInteger(queryString['page']) - 1;
if (parsedPage < 0) parsedPage = 0;
let parsedPageSize = toSafeInteger(queryString['limit']);
if (parsedPageSize > 100) parsedPageSize = 100;
if (parsedPageSize < 5) parsedPageSize = 5;
pagination = {
pageIndex: parsedPage,
pageSize: parsedPageSize,
};
}*/
// sorter
// sorter
const sorter = (queryString.sort ?? '')
.split(',')
.map((token) => token.match(/([+-]?)([\w_]+)/i))
.slice(0, 3)
.map((item) => (item ? { id: item[2], desc: item[1] === '-' } : null))
.filter(Boolean);
/*let sorter = [];
if (sort !== undefined) {
sorter = sort
.split(',')
.map((token) => token.match(/([+-]?)([\w_]+)/i))
.slice(0, 3)
.map((item) =>
item ? { id: item[2], desc: item[1] === '-' } : null
);
}*/
// filters
const filters = Object.entries(queryString)
.filter(([key]) => key !== 'page' && key !== 'limit' && key !== 'sort')
.map(([key, value]) => {
const [, field, , , operator] =
key.match(/([\w]+)(([\[])([\w]+)([\]]))*/i) ?? [];
const sanitizedOperator = _sanitizeOperator(operator ?? '');
return !isEmpty(value)
? { field, operator: sanitizedOperator, value }
: null;
})
.filter(Boolean);
/*let filters = [];
if (filterCandidates !== undefined) {
Object.keys(filterCandidates).map((token) => {
const [, field, , , operator] = token.match( */
// /([\w]+)(([\[])([\w]+)([\]]))*/i
/* );
const value = filterCandidates[token];
if (!isEmpty(value)) {
filters.push({
field,
operator: _sanitizeOperator(operator),
value,
});
}
});
}*/
return {
pagination,
sorter,
filters,
};
};
export const buildQueryString = ({ pagination, sorter, filters }) => {
const params = new URLSearchParams();
if (
pagination &&
pagination.pageIndex !== undefined &&
pagination.pageSize !== undefined
) {
params.append('page', String(pagination.pageIndex + 1));
params.append('limit', String(pagination.pageSize));
}
if (sorter && Array.isArray(sorter) && sorter.length > 0) {
params.append(
'sort',
sorter.map(({ id, desc }) => `${desc ? '-' : ''}${id}`).toString()
);
}
if (filters && Array.isArray(filters) && filters.length > 0) {
filters.forEach((filterItem) => {
if (filterItem.value !== undefined) {
let operator = _mapFilterOperator(filterItem.operator);
if (operator === 'eq') {
params.append(`${filterItem.field}`, filterItem.value);
} else {
params.append(
`${filterItem.field}[${_mapFilterOperator(
filterItem.operator
)}]`,
filterItem.value
);
}
}
});
}
return params.toString();
};
export const combineFilters = (requiredFilters = [], otherFilters = []) => [
...differenceWith(otherFilters, requiredFilters, isEqual),
...requiredFilters,
];
export const unionFilters = (
permanentFilter = [],
newFilters = [],
prevFilters = []
) =>
reverse(
unionWith(
permanentFilter,
newFilters,
prevFilters,
(left, right) =>
left.field == right.field && left.operator == right.operator
)
).filter(
(crudFilter) =>
crudFilter.value !== undefined && crudFilter.value !== null
);
export const extractTableSortPropertiesFromColumn = (columns) => {
const _extractColumnSortProperies = (column) => {
const { canSort, isSorted, sortedIndex, isSortedDesc } = column;
if (!isSorted || !canSort) {
return undefined;
} else {
return {
index: sortedIndex,
field: column.id,
order: isSortedDesc ? 'DESC' : 'ASC',
};
}
};
return columns
.map((column) => _extractColumnSortProperies(column))
.filter((item) => item)
.sort((a, b) => a.index - b.index);
};
export const extractTableSortProperties = (sorter) => {
return sorter.map((sortItem, index) => ({
index,
field: sortItem.id,
order: sortItem.desc ? 'DESC' : 'ASC',
}));
};
export const extractTableFilterProperties = (filters) =>
filters.filter((item) => !isEmpty(item.value));
const _sanitizeOperator = (operator) =>
['eq', 'ne', 'gte', 'lte', 'like'].includes(operator) ? operator : 'eq';
const _mapFilterOperator = (operator) => {
switch (operator) {
case 'ne':
case 'gte':
case 'lte':
return `[${operator}]`;
case 'contains':
return '[like]';
default:
return 'eq';
}
};

View File

@ -1,7 +1,4 @@
import { import { DataTableColumnProps, getDataTableSelectionColumn } from "@/components";
DataTableColumnProps,
getDataTableSelectionColumn,
} from "@/components";
import { IListResponse_DTO } from "@shared/contexts"; import { IListResponse_DTO } from "@shared/contexts";
import { import {
OnChangeFn, OnChangeFn,
@ -12,12 +9,9 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { UseListQueryResult } from "../useDataSource"; import { UseListQueryResult } from "../useDataSource";
import { usePaginationParams } from "../usePagination"; import { usePaginationSyncWithLocation } from "../usePagination";
type TUseDataTableQueryResult<TData, TError> = UseListQueryResult< type TUseDataTableQueryResult<TData, TError> = UseListQueryResult<IListResponse_DTO<TData>, TError>;
IListResponse_DTO<TData>,
TError
>;
type TUseDataTableQuery<TData, TError> = (params: { type TUseDataTableQuery<TData, TError> = (params: {
pagination: { pagination: {
@ -44,16 +38,9 @@ type DataTableColumnsOptionsProps<TData, TValue> = {
columns: DataTableColumnsProps<TData, TValue>; columns: DataTableColumnsProps<TData, TValue>;
}; };
type DataTableColumnsProps<TData, TValue> = DataTableColumnProps< type DataTableColumnsProps<TData, TValue> = DataTableColumnProps<TData, TValue>[];
TData,
TValue
>[];
export const useQueryDataTable = < export const useQueryDataTable = <TData = unknown, TValue = unknown, TError = Error>({
TData = unknown,
TValue = unknown,
TError = Error
>({
fetchQuery, fetchQuery,
enabled = true, enabled = true,
@ -64,7 +51,7 @@ export const useQueryDataTable = <
const defaultData = useMemo(() => [], []); const defaultData = useMemo(() => [], []);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const [pagination, setPagination] = usePaginationParams(); const [pagination, setPagination] = usePaginationSyncWithLocation();
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => { const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {

View File

@ -0,0 +1,12 @@
import { usePagination, usePaginationSyncWithLocation } from "../usePagination";
export const useSyncedPagination = (syncWithLocation: boolean) => {
const [paginationWithLocation, setPaginationWithLocation] = usePaginationSyncWithLocation();
const [paginationWithoutLocation, setPaginationWithoutLocation] = usePagination();
if (syncWithLocation) {
return [paginationWithLocation, setPaginationWithLocation] as const;
} else {
return [paginationWithoutLocation, setPaginationWithoutLocation] as const;
}
};

View File

@ -1,7 +1,6 @@
/* https://github.com/mayank8aug/use-localization/blob/main/src/index.ts */ /* https://github.com/mayank8aug/use-localization/blob/main/src/index.ts */
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LocaleToCurrencyTable, rtlLangsList } from "./utils"; import { LocaleToCurrencyTable, rtlLangsList } from "./utils";
type UseLocalizationProps = { type UseLocalizationProps = {
@ -12,10 +11,10 @@ export const useLocalization = (props: UseLocalizationProps) => {
const { locale } = props; const { locale } = props;
const [lang, loc] = locale.split("-"); const [lang, loc] = locale.split("-");
const { i18n } = useTranslation(); //const { i18n } = useTranslation();
// Obtener el idioma actual // Obtener el idioma actual
const currentLanguage = i18n.language; // const currentLanguage = i18n.language;
const formatCurrency = useCallback( const formatCurrency = useCallback(
(value: number) => { (value: number) => {

View File

@ -1,2 +1,2 @@
export * from "./usePagination"; export * from "./usePagination";
export * from "./usePaginationParams"; export * from "./usePaginationSyncWithLocation";

View File

@ -9,7 +9,7 @@ import { useMemo } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { usePagination } from "./usePagination"; import { usePagination } from "./usePagination";
export const usePaginationParams = ( export const usePaginationSyncWithLocation = (
initialPageIndex: number = INITIAL_PAGE_INDEX, initialPageIndex: number = INITIAL_PAGE_INDEX,
initialPageSize: number = INITIAL_PAGE_SIZE initialPageSize: number = INITIAL_PAGE_SIZE
) => { ) => {
@ -50,5 +50,5 @@ export const usePaginationParams = (
}); });
}; };
return [pagination, updatePagination]; return [pagination, updatePagination] as const;
}; };

View File

@ -0,0 +1,18 @@
import { PropsWithChildren, createContext, useState } from "react";
export interface IUnsavedWarnContextState {
warnWhen?: boolean;
setWarnWhen?: (value: boolean) => void;
}
export const UnsavedWarnContext = createContext<IUnsavedWarnContextState>({});
export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => {
const [warnWhen, setWarnWhen] = useState(false);
return (
<UnsavedWarnContext.Provider value={{ warnWhen, setWarnWhen }}>
{children}
</UnsavedWarnContext.Provider>
);
};

View File

@ -1 +1,3 @@
export * from "./WarnAboutChangeContext";
export * from "./useUnsavedChangesNotifier"; export * from "./useUnsavedChangesNotifier";
export * from "./useWarnAboutChange";

View File

@ -0,0 +1,78 @@
/**
* `useBlocker` and `usePrompt` is no longer part of react-router-dom for the routers other than `DataRouter`.
*
* The previous workaround (<v6.4) was to use `block` function in `UNSAFE_NavigationContext` which is now removed.
*
* We're using a workaround from the gist https://gist.github.com/MarksCode/64e438c82b0b2a1161e01c88ca0d0355 with some modifications
* Thanks to @MarksCode(https://github.com/MarksCode) for the workaround.
*/
import React from "react";
import { UNSAFE_NavigationContext as NavigationContext } from "react-router-dom";
function useConfirmExit(confirmExit: () => boolean, when = true) {
const { navigator } = React.useContext(NavigationContext);
React.useEffect(() => {
if (!when) {
return;
}
const go = navigator.go;
const push = navigator.push;
navigator.push = (...args: Parameters<typeof push>) => {
const result = confirmExit();
if (result !== false) {
push(...args);
}
};
navigator.go = (...args: Parameters<typeof go>) => {
const result = confirmExit();
if (result !== false) {
go(...args);
}
};
return () => {
navigator.push = push;
navigator.go = go;
};
}, [navigator, confirmExit, when]);
}
export function usePrompt(message: string, when = true, onConfirm?: () => void, legacy = false) {
const warnWhenListener = React.useCallback(
(e: { preventDefault: () => void; returnValue: string }) => {
e.preventDefault();
e.returnValue = message;
return e.returnValue;
},
[message]
);
React.useEffect(() => {
if (when && !legacy) {
window.addEventListener("beforeunload", warnWhenListener);
}
return () => {
window.removeEventListener("beforeunload", warnWhenListener);
};
}, [warnWhenListener, when, legacy]);
const confirmExit = React.useCallback(() => {
const confirm = window.confirm(message);
if (confirm && onConfirm) {
onConfirm();
}
return confirm;
}, [message]);
console.log(when);
useConfirmExit(confirmExit, when);
}

View File

@ -1,16 +1,44 @@
import React from "react"; import { CustomDialog } from "@/components";
import { useCustomDialog } from "../useCustomDialog"; import { t } from "i18next";
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { useWarnAboutChange } from "./useWarnAboutChange";
type UnsavedChangesNotifierProps = { type UnsavedChangesNotifierProps = {
translationKey?: string;
message?: string; message?: string;
}; };
export const UnsavedChangesNotifier: React.FC<UnsavedChangesNotifierProps> = () => { export const UnsavedChangesNotifier = ({
const { openDialog: openWarmDialog, DialogComponent: WarmDialog } = useCustomDialog({ message = t("unsaved_changes_prompt"),
title: "Hay cambios sin guardar", }: UnsavedChangesNotifierProps) => {
description: "Are you sure you want to leave? You have unsaved changes.", const { pathname } = useLocation();
const { warnWhen, setWarnWhen } = useWarnAboutChange();
useEffect(() => {
return () => setWarnWhen?.(false);
}, [pathname]);
const warnMessage = useMemo(() => {
return t(message);
}, [message]);
return (
<CustomDialog
cancelLabel='Cancelar'
confirmLabel='Confirmar'
onCancel={() => {}}
title='titulo'
isOpen={warnWhen}
onConfirm={() => {
setWarnWhen?.(false);
}}
description={warnMessage}
/>
);
/*usePrompt(warnMessage, warnWhen, () => {
setWarnWhen?.(false);
}); });
return <>{WarmDialog}</>; return null;*/
}; };

View File

@ -0,0 +1,15 @@
import { useContext } from "react";
import { UnsavedWarnContext } from "./WarnAboutChangeContext";
export const useWarnAboutChange = () => {
const context = useContext(UnsavedWarnContext);
if (context === null)
throw new Error("useWarnAboutChange must be used within a UnsavedWarnProvider");
const { warnWhen, setWarnWhen } = context;
return {
warnWhen: Boolean(warnWhen),
setWarnWhen: setWarnWhen ?? (() => undefined),
};
};

9
client/src/lib/types.ts Normal file
View File

@ -0,0 +1,9 @@
import {
IMoney_Response_DTO,
IPercentage_Response_DTO,
IQuantity_Response_DTO,
} from "@shared/contexts";
export interface IMoney extends IMoney_Response_DTO {}
export interface IQuantity extends IQuantity_Response_DTO {}
export interface IPercentage extends IPercentage_Response_DTO {}

View File

@ -30,7 +30,8 @@
"duplicate_rows": "Duplicar", "duplicate_rows": "Duplicar",
"duplicate_rows_tooltip": "Duplica las fila(s) seleccionadas(s)", "duplicate_rows_tooltip": "Duplica las fila(s) seleccionadas(s)",
"pick_date": "Elige una fecha", "pick_date": "Elige una fecha",
"required_field": "Este campo es obligatorio" "required_field": "Este campo es obligatorio",
"unsaved_changes_prompt": "Los últimos cambios no se han guardado. Si continúas, se perderán"
}, },
"main_menu": { "main_menu": {
"home": "Inicio", "home": "Inicio",
@ -146,6 +147,9 @@
"desc": "desc" "desc": "desc"
} }
} }
},
"edit": {
"title": "Cotización"
} }
}, },
"settings": { "settings": {

View File

@ -15,6 +15,7 @@ export const useAutosizeTextArea = ({
minHeight = 0, minHeight = 0,
}: UseAutosizeTextAreaProps) => { }: UseAutosizeTextAreaProps) => {
const [init, setInit] = React.useState(true); const [init, setInit] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea // We need to reset the height momentarily to get the correct scrollHeight for the textarea
const offsetBorder = 2; const offsetBorder = 2;
@ -36,5 +37,5 @@ export const useAutosizeTextArea = ({
textAreaRef.style.height = `${scrollHeight + offsetBorder}px`; textAreaRef.style.height = `${scrollHeight + offsetBorder}px`;
} }
} }
}, [textAreaRef, triggerAutoSize]); }, [textAreaRef, triggerAutoSize, init, maxHeight, minHeight]);
}; };

View File

@ -4,13 +4,14 @@ import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { import {
Collection, Collection,
Currency, CurrencyData,
Description, Description,
DomainError, DomainError,
ICreateQuote_Request_DTO, ICreateQuote_Request_DTO,
IDomainError, IDomainError,
Language, Language,
Note, Note,
Percentage,
Quantity, Quantity,
Result, Result,
UTCDateValue, UTCDateValue,
@ -153,7 +154,7 @@ export class CreateQuoteUseCase
return Result.fail(customerOrError.error); return Result.fail(customerOrError.error);
} }
const currencyOrError = Currency.createFromCode(quoteDTO.currency_code); const currencyOrError = CurrencyData.createFromCode(quoteDTO.currency_code);
if (currencyOrError.isFailure) { if (currencyOrError.isFailure) {
return Result.fail(currencyOrError.error); return Result.fail(currencyOrError.error);
} }
@ -177,13 +178,15 @@ export class CreateQuoteUseCase
quoteDTO.items?.map( quoteDTO.items?.map(
(item) => (item) =>
QuoteItem.create({ QuoteItem.create({
articleId: item.article_id,
description: Description.create(item.description).object, description: Description.create(item.description).object,
quantity: Quantity.create({ amount: item.quantity, precision: 4 }).object, quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({ unitPrice: UnitPrice.create({
amount: item.unit_price.amount, amount: item.unit_price.amount,
currencyCode: item.unit_price.currency, currencyCode: item.unit_price.currency_code,
precision: item.unit_price.precision, precision: item.unit_price.precision,
}).object, }).object,
discount: Percentage.create(item.discount.amount).object,
}).object }).object
) )
); );

View File

@ -7,10 +7,11 @@ import {
import { IRepositoryManager } from "@/contexts/common/domain"; import { IRepositoryManager } from "@/contexts/common/domain";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { Result, UniqueID } from "@shared/contexts"; import { Result, UniqueID } from "@shared/contexts";
import { IQuoteRepository } from "../../domain"; import { Dealer, IQuoteRepository } from "../../domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure"; import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { Quote } from "../../domain/entities/Quotes/Quote"; import { Quote } from "../../domain/entities/Quotes/Quote";
import { ISalesContext } from "../../infrastructure";
export interface IGetQuoteUseCaseRequest extends IUseCaseRequest { export interface IGetQuoteUseCaseRequest extends IUseCaseRequest {
id: UniqueID; id: UniqueID;
@ -25,14 +26,12 @@ export class GetQuoteUseCase
{ {
private _adapter: ISequelizeAdapter; private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager; private _repositoryManager: IRepositoryManager;
private _dealer?: Dealer;
constructor(props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) { constructor(context: ISalesContext) {
this._adapter = props.adapter; this._adapter = context.adapter;
this._repositoryManager = props.repositoryManager; this._repositoryManager = context.repositoryManager;
} this._dealer = context.dealer;
private getRepositoryByName<T>(name: string) {
return this._repositoryManager.getRepository<T>(name);
} }
async execute(request: IGetQuoteUseCaseRequest): Promise<GetQuoteResponseOrError> { async execute(request: IGetQuoteUseCaseRequest): Promise<GetQuoteResponseOrError> {
@ -63,9 +62,7 @@ export class GetQuoteUseCase
return Result.ok<Quote>(Quote!); return Result.ok<Quote>(Quote!);
} catch (error: unknown) { } catch (error: unknown) {
const _error = error as IInfrastructureError; const _error = error as IInfrastructureError;
return Result.fail( return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Query error", _error));
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Error al consultar el usuario", _error)
);
} }
} }

View File

@ -58,13 +58,7 @@ export class ListQuotesUseCase implements IUseCase<IListQuotesParams, Promise<Li
} catch (error: unknown) { } catch (error: unknown) {
const _error = error as IInfrastructureError; const _error = error as IInfrastructureError;
console.trace(_error.message); console.trace(_error.message);
return Result.fail( return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Query error", _error));
UseCaseError.create(
UseCaseError.REPOSITORY_ERROR,
"Error al listar las cotizaciones",
_error
)
);
} }
} }

View File

@ -1,10 +1,12 @@
import { import {
AggregateRoot, AggregateRoot,
Currency, CurrencyData,
ICollection, ICollection,
IDomainError, IDomainError,
Language, Language,
MoneyValue,
Note, Note,
Percentage,
Result, Result,
UTCDateValue, UTCDateValue,
UniqueID, UniqueID,
@ -20,13 +22,17 @@ export interface IQuoteProps {
reference: QuoteReference; reference: QuoteReference;
customer: QuoteCustomer; customer: QuoteCustomer;
language: Language; language: Language;
currency: Currency; currency: CurrencyData;
paymentMethod: Note; paymentMethod: Note;
notes: Note; notes: Note;
validity: Note; validity: Note;
items: ICollection<QuoteItem>; items: ICollection<QuoteItem>;
//subtotalPrice: MoneyValue;
discount: Percentage;
//totalPrice: MoneyValue;
dealerId: UniqueID; dealerId: UniqueID;
} }
@ -38,10 +44,15 @@ export interface IQuote {
reference: QuoteReference; reference: QuoteReference;
customer: QuoteCustomer; customer: QuoteCustomer;
language: Language; language: Language;
currency: Currency; currency: CurrencyData;
paymentMethod: Note; paymentMethod: Note;
notes: Note; notes: Note;
validity: Note; validity: Note;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
items: ICollection<QuoteItem>; items: ICollection<QuoteItem>;
dealerId: UniqueID; dealerId: UniqueID;
@ -60,6 +71,14 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
protected _items: ICollection<QuoteItem>; protected _items: ICollection<QuoteItem>;
protected _calculateTotalPriceItems = (): MoneyValue =>
this.props.items
.toArray()
.reduce<MoneyValue>(
(accumulator, currentItem) => accumulator.add(currentItem.subtotalPrice),
MoneyValue.create({ amount: 0, precision: 2, currencyCode: this.currency.code }).object
);
protected constructor(props: IQuoteProps, id?: UniqueID) { protected constructor(props: IQuoteProps, id?: UniqueID) {
super(props, id); super(props, id);
@ -113,4 +132,16 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
get dealerId() { get dealerId() {
return this.props.dealerId; return this.props.dealerId;
} }
get subtotalPrice(): MoneyValue {
return this._calculateTotalPriceItems();
}
get discount(): Percentage {
return this.props.discount;
}
get totalPrice(): MoneyValue {
return this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber()));
}
} }

View File

@ -4,21 +4,30 @@ import {
IDomainError, IDomainError,
IEntityProps, IEntityProps,
MoneyValue, MoneyValue,
Percentage,
Quantity, Quantity,
Result, Result,
UniqueID, UniqueID,
} from "@shared/contexts"; } from "@shared/contexts";
export interface IQuoteItemProps extends IEntityProps { export interface IQuoteItemProps extends IEntityProps {
articleId: string;
description: Description; // Descripción del artículo o servicio description: Description; // Descripción del artículo o servicio
quantity: Quantity; // Cantidad de unidades quantity: Quantity; // Cantidad de unidades
unitPrice: MoneyValue; // Precio unitario en la moneda de la factura unitPrice: MoneyValue; // Precio unitario en la moneda de la factura
// subtotalPrice: MoneyValue; // Precio unitario * Cantidad
discount: Percentage; // % descuento
// totalPrice: MoneyValue;
} }
export interface IQuoteItem { export interface IQuoteItem {
articleId: string;
description: Description; description: Description;
quantity: Quantity; quantity: Quantity;
unitPrice: MoneyValue; unitPrice: MoneyValue;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
} }
export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem { export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem {
@ -26,6 +35,10 @@ export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem {
return Result.ok(new QuoteItem(props, id)); return Result.ok(new QuoteItem(props, id));
} }
get articleId(): string {
return this.props.articleId;
}
get description(): Description { get description(): Description {
return this.props.description; return this.props.description;
} }
@ -37,4 +50,16 @@ export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem {
get unitPrice(): MoneyValue { get unitPrice(): MoneyValue {
return this.props.unitPrice; return this.props.unitPrice;
} }
get subtotalPrice(): MoneyValue {
return this.unitPrice.multiply(this.quantity.toNumber());
}
get discount(): Percentage {
return this.props.discount;
}
get totalPrice(): MoneyValue {
return this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber()));
}
} }

View File

@ -1,4 +1,8 @@
import { ICollection, IGetQuote_Response_DTO } from "@shared/contexts"; import {
ICollection,
IGetQuote_QuoteItem_Response_DTO,
IGetQuote_Response_DTO,
} from "@shared/contexts";
import { Quote, QuoteItem } from "../../../../../../domain"; import { Quote, QuoteItem } from "../../../../../../domain";
import { ISalesContext } from "../../../../../Sales.context"; import { ISalesContext } from "../../../../../Sales.context";
@ -13,44 +17,38 @@ export const GetQuotePresenter: IGetQuotePresenter = {
id: quote.id.toString(), id: quote.id.toString(),
status: quote.status.toString(), status: quote.status.toString(),
date: quote.date.toString(), date: quote.date.toString(),
language_code: quote.date.toString(), reference: quote.reference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.code,
currency_code: quote.currency.toString(), currency_code: quote.currency.toString(),
subtotal: {
amount: 0, payment_method: quote.paymentMethod.toString(),
precision: 2, validity: quote.validity.toString(),
currency: "EUR", notes: quote.notes.toString(),
},
total: { subtotal_price: quote.subtotalPrice.toObject(),
amount: 0, discount: quote.discount.toObject(),
precision: 2, total_price: quote.totalPrice.toObject(),
currency: "EUR",
},
items: quoteItemPresenter(quote.items, context), items: quoteItemPresenter(quote.items, context),
dealer_id: quote.dealerId.toString(),
}; };
}, },
}; };
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const quoteItemPresenter = (items: ICollection<QuoteItem>, context: ISalesContext) => const quoteItemPresenter = (
items: ICollection<QuoteItem>,
context: ISalesContext
): IGetQuote_QuoteItem_Response_DTO[] =>
items.totalCount > 0 items.totalCount > 0
? items.items.map((item: QuoteItem) => ({ ? items.items.map((item: QuoteItem) => ({
article_id: item.articleId,
description: item.description.toString(), description: item.description.toString(),
quantity: item.quantity.toString(), quantity: item.quantity.toObject(),
unit_measure: "", unit_price: item.unitPrice.toObject(),
unit_price: { subtotal_price: item.subtotalPrice.toObject(),
amount: 0, discount: item.discount.toObject(),
precision: 2, total_price: item.totalPrice.toObject(),
currency: "EUR",
},
subtotal: {
amount: 0,
precision: 2,
currency: "EUR",
},
total: {
amount: 0,
precision: 2,
currency: "EUR",
},
})) }))
: []; : [];

View File

@ -1,5 +1,6 @@
import { ListQuotesUseCase } from "@/contexts/sales/application"; import { ListQuotesUseCase } from "@/contexts/sales/application";
import { registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository"; import { registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import Express from "express"; import Express from "express";
import { ListQuotesController } from "./ListQuotes.controller"; import { ListQuotesController } from "./ListQuotes.controller";
import { ListQuotesPresenter } from "./presenter"; import { ListQuotesPresenter } from "./presenter";
@ -9,7 +10,7 @@ export const listQuotesController = (
res: Express.Response, res: Express.Response,
next: Express.NextFunction next: Express.NextFunction
) => { ) => {
const context = res.locals.context; const context: ISalesContext = res.locals.context;
registerQuoteRepository(context); registerQuoteRepository(context);

View File

@ -1,4 +1,4 @@
import { Currency, Language, Note, UTCDateValue, UniqueID } from "@shared/contexts"; import { CurrencyData, Language, Note, Percentage, UTCDateValue, UniqueID } from "@shared/contexts";
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure"; import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { IQuoteProps, Quote, QuoteCustomer, QuoteReference } from "../../domain"; import { IQuoteProps, Quote, QuoteCustomer, QuoteReference } from "../../domain";
@ -33,18 +33,41 @@ class QuoteMapper
const props: IQuoteProps = { const props: IQuoteProps = {
status: this.mapsValue(source, "status", QuoteStatus.create), status: this.mapsValue(source, "status", QuoteStatus.create),
date: this.mapsValue(source, "issue_date", UTCDateValue.create), date: this.mapsValue(source, "date", UTCDateValue.create),
reference: this.mapsValue(source, "reference", QuoteReference.create), reference: this.mapsValue(source, "reference", QuoteReference.create),
currency: this.mapsValue(source, "quote_currency", Currency.createFromCode), currency: this.mapsValue(source, "currency_code", CurrencyData.createFromCode),
language: this.mapsValue(source, "quote_language", Language.createFromCode), language: this.mapsValue(source, "lang_code", Language.createFromCode),
customer: this.mapsValue(source, "customer", QuoteCustomer.create), customer: this.mapsValue(source, "customer_information", QuoteCustomer.create),
validity: this.mapsValue(source, "validity", Note.create), validity: this.mapsValue(source, "validity", Note.create),
paymentMethod: this.mapsValue(source, "paymentMethod", Note.create), paymentMethod: this.mapsValue(source, "payment_method", Note.create),
notes: this.mapsValue(source, "notes", Note.create), notes: this.mapsValue(source, "notes", Note.create),
items, items,
/*subtotal: this.mapsValue(source, "subtotal_price", (subtotal_price) =>
MoneyValue.create({
amount: subtotal_price,
currencyCode: source.currency_code,
precision: 2,
})
),*/
discount: this.mapsValue(source, "discount", (discount) =>
Percentage.create({
amount: discount,
precision: Percentage.DEFAULT_PRECISION,
})
),
/*totalPrice: this.mapsValue(source, "total_price", (total_price) =>
MoneyValue.create({
amount: total_price,
currencyCode: source.currency_code,
precision: 2,
})
),*/
dealerId: this.mapsValue(source, "dealer_id", UniqueID.create), dealerId: this.mapsValue(source, "dealer_id", UniqueID.create),
}; };
@ -75,9 +98,9 @@ class QuoteMapper
payment_method: source.paymentMethod.toPrimitive(), payment_method: source.paymentMethod.toPrimitive(),
notes: source.notes.toPrimitive(), notes: source.notes.toPrimitive(),
discount: 0, subtotal_price: source.subtotalPrice.toPrimitive(),
subtotal: 0, discount: source.discount.toPrimitive(),
total: 0, total_price: source.totalPrice.toPrimitive(),
items, items,
dealer_id: source.dealerId.toPrimitive(), dealer_id: source.dealerId.toPrimitive(),

View File

@ -1,5 +1,5 @@
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure"; import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { Description, Quantity, UniqueID, UnitPrice } from "@shared/contexts"; import { Description, MoneyValue, Percentage, Quantity, UniqueID } from "@shared/contexts";
import { IQuoteItemProps, Quote, QuoteItem } from "../../domain"; import { IQuoteItemProps, Quote, QuoteItem } from "../../domain";
import { ISalesContext } from "../Sales.context"; import { ISalesContext } from "../Sales.context";
import { Quote_Model } from "../sequelize"; import { Quote_Model } from "../sequelize";
@ -23,15 +23,40 @@ class QuoteItemMapper
const id = this.mapsValue(source, "item_id", UniqueID.create); const id = this.mapsValue(source, "item_id", UniqueID.create);
const props: IQuoteItemProps = { const props: IQuoteItemProps = {
articleId: source.id_article,
description: this.mapsValue(source, "description", Description.create), description: this.mapsValue(source, "description", Description.create),
quantity: this.mapsValue(source, "quantity", Quantity.create), quantity: this.mapsValue(source, "quantity", Quantity.create),
unitPrice: this.mapsValue(source, "unit_price", (unit_price) => unitPrice: this.mapsValue(source, "unit_price", (unit_price) =>
UnitPrice.create({ MoneyValue.create({
amount: unit_price, amount: unit_price,
currencyCode: sourceParent.currency_code, currencyCode: sourceParent.currency_code,
precision: 4, precision: 4,
}) })
), ),
/*subtotalPrice: this.mapsValue(source, "subtotal_price", (subtotal_price) =>
MoneyValue.create({
amount: subtotal_price,
currencyCode: sourceParent.currency_code,
precision: 4,
})
),
*/
discount: this.mapsValue(source, "discount", (discount) =>
Percentage.create({
amount: discount,
precision: Percentage.DEFAULT_PRECISION,
})
),
/*totalPrice: this.mapsValue(source, "total_price", (total_price) =>
MoneyValue.create({
amount: total_price,
currencyCode: sourceParent.currency_code,
precision: 2,
})
),*/
}; };
const quoteItemOrError = QuoteItem.create(props, id); const quoteItemOrError = QuoteItem.create(props, id);
@ -50,16 +75,16 @@ class QuoteItemMapper
const { index, sourceParent } = params; const { index, sourceParent } = params;
return { return {
item_id: source.id.toString(),
quote_id: sourceParent.id.toPrimitive(), quote_id: sourceParent.id.toPrimitive(),
position: index, position: index,
item_id: "", //article_id: source.id.toPrimitive(), id_article: source.articleId,
description: source.description.toPrimitive(), description: source.description.toPrimitive(),
quantity: source.quantity.toPrimitive(), quantity: source.quantity.toPrimitive(),
unit_price: source.unitPrice.toPrimitive(), unit_price: source.unitPrice.toPrimitive(),
subtotal: 0, subtotal_price: source.subtotalPrice.toPrimitive(),
total: 0, discount: source.discount.toPrimitive(),
//subtotal: source.calculateSubtotal().toPrimitive(), total_price: source.totalPrice.toPrimitive(),
//total: source.calculateTotal().toPrimitive(),
}; };
} }
} }

View File

@ -50,9 +50,9 @@ export class Quote_Model extends Model<
declare notes: CreationOptional<string>; declare notes: CreationOptional<string>;
declare validity: CreationOptional<string>; declare validity: CreationOptional<string>;
declare subtotal: CreationOptional<number>; declare subtotal_price: CreationOptional<number>;
declare discount: CreationOptional<number>; declare discount: CreationOptional<number>;
declare total: CreationOptional<number>; declare total_price: CreationOptional<number>;
declare items: NonAttribute<QuoteItem_Model[]>; declare items: NonAttribute<QuoteItem_Model[]>;
declare dealer: NonAttribute<Dealer_Model>; declare dealer: NonAttribute<Dealer_Model>;
@ -108,7 +108,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
subtotal: { subtotal_price: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: true, allowNull: true,
}, },
@ -118,7 +118,7 @@ export default (sequelize: Sequelize) => {
allowNull: true, allowNull: true,
}, },
total: { total_price: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: true, allowNull: true,
}, },

View File

@ -30,12 +30,14 @@ export class QuoteItem_Model extends Model<
declare quote_id: string; declare quote_id: string;
declare item_id: string; declare item_id: string;
declare id_article: string; // number ??
declare position: number; declare position: number;
declare description: CreationOptional<string>; declare description: CreationOptional<string>;
declare quantity: CreationOptional<number>; declare quantity: CreationOptional<number>;
declare unit_price: CreationOptional<number>; declare unit_price: CreationOptional<number>;
declare subtotal: CreationOptional<number>; declare subtotal_price: CreationOptional<number>;
declare total: CreationOptional<number>; declare discount: CreationOptional<number>;
declare total_price: CreationOptional<number>;
declare quote: NonAttribute<Quote_Model>; declare quote: NonAttribute<Quote_Model>;
} }
@ -51,6 +53,10 @@ export default (sequelize: Sequelize) => {
type: new DataTypes.UUID(), type: new DataTypes.UUID(),
primaryKey: true, primaryKey: true,
}, },
id_article: {
type: DataTypes.BIGINT().UNSIGNED,
allowNull: false,
},
position: { position: {
type: new DataTypes.MEDIUMINT(), type: new DataTypes.MEDIUMINT(),
autoIncrement: false, autoIncrement: false,
@ -68,11 +74,15 @@ export default (sequelize: Sequelize) => {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: true, allowNull: true,
}, },
subtotal: { subtotal_price: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: true, allowNull: true,
}, },
total: { discount: {
type: DataTypes.BIGINT(),
allowNull: true,
},
total_price: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: true, allowNull: true,
}, },

View File

@ -1,7 +1,9 @@
import { checkUser } from "@/contexts/auth"; import { checkUser } from "@/contexts/auth";
import { import {
createQuoteController, createQuoteController,
getQuoteController,
listQuotesController, listQuotesController,
updateQuoteController,
} from "@/contexts/sales/infrastructure/express/controllers"; } from "@/contexts/sales/infrastructure/express/controllers";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/dealerMiddleware"; import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/dealerMiddleware";
import Express from "express"; import Express from "express";
@ -10,11 +12,11 @@ export const QuoteRouter = (appRouter: Express.Router) => {
const quoteRoutes: Express.Router = Express.Router({ mergeParams: true }); const quoteRoutes: Express.Router = Express.Router({ mergeParams: true });
quoteRoutes.get("/", checkUser, getDealerMiddleware, listQuotesController); quoteRoutes.get("/", checkUser, getDealerMiddleware, listQuotesController);
quoteRoutes.get("/:quoteId", checkUser, getDealerMiddleware, getQuoteController);
quoteRoutes.post("/", checkUser, getDealerMiddleware, createQuoteController); quoteRoutes.post("/", checkUser, getDealerMiddleware, createQuoteController);
quoteRoutes.put("/:quoteId", checkUser, updateQuoteController);
//quoteRoutes.put("/:quoteId", checkUser, updateQuoteController); /*
/*quoteRoutes.get("/:quoteId", isUser, getQuoteMiddleware, getQuoteController);
quoteRoutes.post("/", isAdmin, createQuoteController); quoteRoutes.post("/", isAdmin, createQuoteController);
quoteRoutes.delete("/:quoteId", isAdmin, deleteQuoteController);*/ quoteRoutes.delete("/:quoteId", isAdmin, deleteQuoteController);*/

View File

@ -1,4 +1,4 @@
import { IMoney_Response_DTO } from "../../../../common"; import { IQuantuty_Response_DTO } from "../../../../common";
export interface IListArticles_Response_DTO { export interface IListArticles_Response_DTO {
id: string; id: string;
@ -10,5 +10,5 @@ export interface IListArticles_Response_DTO {
description: string; description: string;
points: number; points: number;
retail_price: IMoney_Response_DTO; retail_price: IQuantuty_Response_DTO;
} }

View File

@ -1,5 +1,5 @@
import { import {
Currency, CurrencyData,
Description, Description,
Email, Email,
Language, Language,
@ -46,7 +46,7 @@ export const ensureDateIsValid = (value: string): Result<boolean, Error> => {
}; };
export const ensureCurrencyCodeIsValid = (value: string): Result<boolean, Error> => { export const ensureCurrencyCodeIsValid = (value: string): Result<boolean, Error> => {
const currencyOrError = Currency.createFromCode(value); const currencyOrError = CurrencyData.createFromCode(value);
return currencyOrError.isSuccess ? Result.ok(true) : Result.fail(currencyOrError.error); return currencyOrError.isSuccess ? Result.ok(true) : Result.fail(currencyOrError.error);
}; };

View File

@ -4,14 +4,14 @@ import { Result, RuleValidator } from "../../domain";
export interface IMoney_Request_DTO { export interface IMoney_Request_DTO {
amount: number; amount: number;
precision: number; precision: number;
currency: string; currency_code: string;
} }
export function ensureMoney_DTOIsValid(money: IMoney_Request_DTO) { export function ensureMoney_DTOIsValid(money: IMoney_Request_DTO) {
const schema = Joi.object({ const schema = Joi.object({
amount: Joi.number(), amount: Joi.number(),
precision: Joi.number(), precision: Joi.number(),
currency: Joi.string(), currency_code: Joi.string(),
}); });
const result = RuleValidator.validate<IMoney_Request_DTO>(schema, money); const result = RuleValidator.validate<IMoney_Request_DTO>(schema, money);

View File

@ -1,6 +1,7 @@
export interface IPercentage_DTO { export interface IPercentage_DTO {
amount: number; amount: number;
precision: number; precision: number;
} }
export interface IPercentage_Request_DTO extends IPercentage_DTO {}
export interface IPercentage_Response_DTO extends IPercentage_DTO {} export interface IPercentage_Response_DTO extends IPercentage_DTO {}

View File

@ -0,0 +1,24 @@
import Joi from "joi";
import { Result, RuleValidator } from "../../domain";
export interface IQuantity_Request_DTO {
amount: number;
precision: number;
}
export function ensureQuantity_DTOIsValid(quantity: IQuantity_Request_DTO) {
const schema = Joi.object({
amount: Joi.number(),
precision: Joi.number(),
});
const result = RuleValidator.validate<IQuantity_Request_DTO>(schema, quantity);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(true);
}
export interface IQuantity_Response_DTO extends IQuantity_Request_DTO {}

View File

@ -1,4 +1,5 @@
export * from "./IError_Response.dto"; export * from "./IError_Response.dto";
export * from "./IMoney.dto"; export * from "./IMoney.dto";
export * from "./IPercentage.dto"; export * from "./IPercentage.dto";
export * from "./IQuantity.dto";
export * from "./ITaxType.dto"; export * from "./ITaxType.dto";

View File

@ -1,6 +1,8 @@
export interface ICollection<T> { export interface ICollection<T> {
items: T[]; items: T[];
totalCount: number; totalCount: number;
toArray(): T[];
} }
export class Collection<T> implements ICollection<T> { export class Collection<T> implements ICollection<T> {
@ -15,7 +17,7 @@ export class Collection<T> implements ICollection<T> {
return Array.from(this._items.values()).reduce( return Array.from(this._items.values()).reduce(
(total, item) => (item !== undefined ? total + 1 : total), (total, item) => (item !== undefined ? total + 1 : total),
0, 0
); );
} }
@ -26,10 +28,8 @@ export class Collection<T> implements ICollection<T> {
constructor(initialValues?: T[], totalCount?: number) { constructor(initialValues?: T[], totalCount?: number) {
this._items = new Map<number, T>( this._items = new Map<number, T>(
initialValues initialValues
? initialValues.map( ? initialValues.map((value: any, index: number) => [index, value] as [number, T])
(value: any, index: number) => [index, value] as [number, T], : []
)
: [],
); );
this._totalCountIsProvided = typeof totalCount === "number"; this._totalCountIsProvided = typeof totalCount === "number";
@ -89,9 +89,7 @@ export class Collection<T> implements ICollection<T> {
} }
} }
public find( public find(predicate: (value: T, index: number, obj: T[]) => unknown): T | undefined {
predicate: (value: T, index: number, obj: T[]) => unknown,
): T | undefined {
return Array.from(this._items.values()).find(predicate); return Array.from(this._items.values()).find(predicate);
} }
@ -106,8 +104,8 @@ export class Collection<T> implements ICollection<T> {
} }
public toStringArray(): string[] { public toStringArray(): string[] {
return Array.from(this._items.values(), (element) => return Array.from(this._items.values(), (element) => JSON.stringify(element)).filter(
JSON.stringify(element), (element) => element.length > 0
).filter((element) => element.length > 0); );
} }
} }

View File

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

View File

@ -5,7 +5,7 @@ import { DomainError, handleDomainError } from "../../errors";
import { INullableValueObjectOptions, NullableValueObject } from "../NullableValueObject"; import { INullableValueObjectOptions, NullableValueObject } from "../NullableValueObject";
import { Result } from "../Result"; import { Result } from "../Result";
export interface ICurrency { export interface ICurrencyData {
symbol: string; symbol: string;
name: string; name: string;
symbol_native: string; symbol_native: string;
@ -15,9 +15,9 @@ export interface ICurrency {
name_plural: string; name_plural: string;
} }
export interface ICurrencyOptions extends INullableValueObjectOptions {} export interface ICurrencyDataOptions extends INullableValueObjectOptions {}
export class Currency extends NullableValueObject<ICurrency> { export class CurrencyData extends NullableValueObject<ICurrencyData> {
public static readonly DEFAULT_CURRENCY_CODE = "EUR"; public static readonly DEFAULT_CURRENCY_CODE = "EUR";
public static readonly CURRENCIES = Currencies; public static readonly CURRENCIES = Currencies;
@ -29,7 +29,7 @@ export class Currency extends NullableValueObject<ICurrency> {
return this.props ? String(this.props.code) : ""; return this.props ? String(this.props.code) : "";
} }
protected static validate(value: string, options: ICurrencyOptions) { protected static validate(value: string, options: ICurrencyDataOptions) {
const rule = Joi.alternatives( const rule = Joi.alternatives(
RuleValidator.RULE_ALLOW_EMPTY.default(""), RuleValidator.RULE_ALLOW_EMPTY.default(""),
Joi.string() Joi.string()
@ -41,24 +41,24 @@ export class Currency extends NullableValueObject<ICurrency> {
return RuleValidator.validate<string>(rule, value); return RuleValidator.validate<string>(rule, value);
} }
public static createFromCode(currencyCode: string, options: ICurrencyOptions = {}) { public static createFromCode(currencyCode: string, options: ICurrencyDataOptions = {}) {
const _options = { const _options = {
...options, ...options,
label: options.label ? options.label : "current_code", label: options.label ? options.label : "current_code",
}; };
const validationResult = Currency.validate(currencyCode, _options); const validationResult = CurrencyData.validate(currencyCode, _options);
if (validationResult.isFailure) { if (validationResult.isFailure) {
return Result.fail( return Result.fail(
handleDomainError(DomainError.INVALID_INPUT_DATA, validationResult.error.message, _options) handleDomainError(DomainError.INVALID_INPUT_DATA, validationResult.error.message, _options)
); );
} }
return Result.ok(new Currency(Currencies[validationResult.object])); return Result.ok(new CurrencyData(Currencies[validationResult.object]));
} }
public static createDefaultCode() { public static createDefaultCode() {
return Currency.createFromCode(Currency.DEFAULT_CURRENCY_CODE); return CurrencyData.createFromCode(CurrencyData.DEFAULT_CURRENCY_CODE);
} }
public isEmpty(): boolean { public isEmpty(): boolean {

View File

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

View File

@ -1,27 +1,28 @@
/* eslint-disable no-use-before-define */ /* eslint-disable no-use-before-define */
import DineroFactory, { Dinero } from "dinero.js"; import DineroFactory, { Dinero } from "dinero.js";
import { Currency } from "./Currency";
import Joi from "joi"; import Joi from "joi";
import { isNull } from "lodash"; import { isNull } from "lodash";
import { NullOr } from "../../../../utilities"; import { NullOr } from "../../../../utilities";
import { RuleValidator } from "../RuleValidator"; import { RuleValidator } from "../RuleValidator";
import { CurrencyData } from "./CurrencyData";
import { Result } from "./Result"; import { Result } from "./Result";
import { IValueObjectOptions, ValueObject } from "./ValueObject"; import { IValueObjectOptions, ValueObject } from "./ValueObject";
export interface IMoneyValueOptions extends IValueObjectOptions { export interface IMoneyValueOptions extends IValueObjectOptions {
defaultValue?: number;
locale: string; locale: string;
} }
export const defaultMoneyValueOptions: IMoneyValueOptions = { export const defaultMoneyValueOptions: IMoneyValueOptions = {
defaultValue: 0,
locale: "es-ES", locale: "es-ES",
}; };
export interface MoneyValueObject { export interface MoneyValueObject {
amount: number; amount: number;
precision: number; precision: number;
currency: string; currency_code: string;
} }
type RoundingMode = type RoundingMode =
@ -43,7 +44,7 @@ export interface IMoneyValueProps {
const defaultMoneyValueProps = { const defaultMoneyValueProps = {
amount: 0, amount: 0,
currencyCode: Currency.DEFAULT_CURRENCY_CODE, currencyCode: CurrencyData.DEFAULT_CURRENCY_CODE,
precision: 2, precision: 2,
}; };
@ -56,13 +57,10 @@ interface IMoneyValue {
isNull(): boolean; isNull(): boolean;
getAmount(): number; getAmount(): number;
getCurrency(): Currency; getCurrency(): CurrencyData;
getLocale(): string; getLocale(): string;
getPrecision(): number; getPrecision(): number;
convertPrecision( convertPrecision(newPrecision: number, roundingMode?: RoundingMode): MoneyValue;
newPrecision: number,
roundingMode?: RoundingMode,
): MoneyValue;
add(addend: MoneyValue): MoneyValue; add(addend: MoneyValue): MoneyValue;
subtract(subtrahend: MoneyValue): MoneyValue; subtract(subtrahend: MoneyValue): MoneyValue;
@ -90,16 +88,16 @@ interface IMoneyValue {
} }
export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue { export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
public static readonly DEFAULT_PRECISION = defaultMoneyValueProps.precision;
public static readonly DEFAULT_CURRENCY_CODE = defaultMoneyValueProps.currencyCode;
private static readonly MIN_VALUE = Number.MIN_VALUE; private static readonly MIN_VALUE = Number.MIN_VALUE;
private static readonly MAX_VALUE = Number.MAX_VALUE; private static readonly MAX_VALUE = Number.MAX_VALUE;
private readonly _isNull: boolean; private readonly _isNull: boolean;
private readonly _options: IMoneyValueOptions; private readonly _options: IMoneyValueOptions;
protected static validate( protected static validate(amount: NullOr<number | string>, options: IMoneyValueOptions) {
amount: NullOr<number | string>,
options: IMoneyValueOptions,
) {
const ruleNull = Joi.any() const ruleNull = Joi.any()
.optional() // <- undefined .optional() // <- undefined
.valid(null); // <- null .valid(null); // <- null
@ -131,7 +129,7 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
public static create( public static create(
props: IMoneyValueProps = defaultMoneyValueProps, props: IMoneyValueProps = defaultMoneyValueProps,
options = defaultMoneyValueOptions, options = defaultMoneyValueOptions
) { ) {
if (props === null) { if (props === null) {
throw new Error(`InvalidParams: props params is missing`); throw new Error(`InvalidParams: props params is missing`);
@ -141,7 +139,7 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
amount = defaultMoneyValueProps.amount, amount = defaultMoneyValueProps.amount,
currencyCode = defaultMoneyValueProps.currencyCode, currencyCode = defaultMoneyValueProps.currencyCode,
precision = defaultMoneyValueProps.precision, precision = defaultMoneyValueProps.precision,
} = props; } = props || {};
const validationResult = MoneyValue.validate(amount, options); const validationResult = MoneyValue.validate(amount, options);
@ -149,13 +147,11 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
return Result.fail(validationResult.error); return Result.fail(validationResult.error);
} }
const _amount: NullOr<number> = MoneyValue.sanitize( const _amount: NullOr<number> = MoneyValue.sanitize(validationResult.object);
validationResult.object,
);
const prop = DineroFactory({ const prop = DineroFactory({
amount: !isNull(_amount) ? _amount : 0, amount: !isNull(_amount) ? _amount : options.defaultValue,
currency: Currency.DEFAULT_CURRENCY_CODE, currency: CurrencyData.createFromCode(currencyCode).object.code,
precision, precision,
}).setLocale(options.locale); }).setLocale(options.locale);
@ -175,29 +171,23 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
} }
protected static createFromDinero(dinero: Dinero) { protected static createFromDinero(dinero: Dinero) {
return Result.ok<MoneyValue>( return Result.ok<MoneyValue>(new MoneyValue(dinero, false, defaultMoneyValueOptions));
new MoneyValue(dinero, false, defaultMoneyValueOptions), }
public static normalizePrecision(objects: ReadonlyArray<MoneyValue>): MoneyValue[] {
return DineroFactory.normalizePrecision(objects.map((object) => object.props)).map(
(dinero) => MoneyValue.createFromDinero(dinero).object
); );
} }
public static normalizePrecision(
objects: ReadonlyArray<MoneyValue>,
): MoneyValue[] {
return DineroFactory.normalizePrecision(
objects.map((object) => object.props),
).map((dinero) => MoneyValue.createFromDinero(dinero).object);
}
public static minimum(objects: ReadonlyArray<MoneyValue>): MoneyValue { public static minimum(objects: ReadonlyArray<MoneyValue>): MoneyValue {
return MoneyValue.createFromDinero( return MoneyValue.createFromDinero(DineroFactory.minimum(objects.map((object) => object.props)))
DineroFactory.minimum(objects.map((object) => object.props)), .object;
).object;
} }
public static maximum(objects: ReadonlyArray<MoneyValue>): MoneyValue { public static maximum(objects: ReadonlyArray<MoneyValue>): MoneyValue {
return MoneyValue.createFromDinero( return MoneyValue.createFromDinero(DineroFactory.maximum(objects.map((object) => object.props)))
DineroFactory.maximum(objects.map((object) => object.props)), .object;
).object;
} }
constructor(value: Dinero, isNull: boolean, options: IMoneyValueOptions) { constructor(value: Dinero, isNull: boolean, options: IMoneyValueOptions) {
@ -238,17 +228,13 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
return this.props.getPrecision(); return this.props.getPrecision();
} }
public convertPrecision( public convertPrecision(newPrecision: number, roundingMode?: RoundingMode): MoneyValue {
newPrecision: number, return MoneyValue.createFromDinero(this.props.convertPrecision(newPrecision, roundingMode))
roundingMode?: RoundingMode, .object;
): MoneyValue {
return MoneyValue.createFromDinero(
this.props.convertPrecision(newPrecision, roundingMode),
).object;
} }
public getCurrency(): Currency { public getCurrency(): CurrencyData {
return Currency.createFromCode(this.props.getCurrency()).object; return CurrencyData.createFromCode(this.props.getCurrency()).object;
} }
public getLocale(): string { public getLocale(): string {
@ -260,34 +246,23 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
} }
public subtract(subtrahend: MoneyValue): MoneyValue { public subtract(subtrahend: MoneyValue): MoneyValue {
return MoneyValue.createFromDinero(this.props.subtract(subtrahend.props)) return MoneyValue.createFromDinero(this.props.subtract(subtrahend.props)).object;
.object;
} }
public multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue { public multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue {
return MoneyValue.createFromDinero( return MoneyValue.createFromDinero(this.props.multiply(multiplier, roundingMode)).object;
this.props.multiply(multiplier, roundingMode),
).object;
} }
public divide(divisor: number, roundingMode?: RoundingMode): MoneyValue { public divide(divisor: number, roundingMode?: RoundingMode): MoneyValue {
return MoneyValue.createFromDinero(this.props.divide(divisor, roundingMode)) return MoneyValue.createFromDinero(this.props.divide(divisor, roundingMode)).object;
.object;
} }
public percentage( public percentage(percentage: number, roundingMode?: RoundingMode): MoneyValue {
percentage: number, return MoneyValue.createFromDinero(this.props.percentage(percentage, roundingMode)).object;
roundingMode?: RoundingMode,
): MoneyValue {
return MoneyValue.createFromDinero(
this.props.percentage(percentage, roundingMode),
).object;
} }
public allocate(ratios: ReadonlyArray<number>): MoneyValue[] { public allocate(ratios: ReadonlyArray<number>): MoneyValue[] {
return this.props return this.props.allocate(ratios).map((dinero) => MoneyValue.createFromDinero(dinero).object);
.allocate(ratios)
.map((dinero) => MoneyValue.createFromDinero(dinero).object);
} }
public equalsTo(comparator: MoneyValue): boolean { public equalsTo(comparator: MoneyValue): boolean {
@ -347,7 +322,7 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
return { return {
amount: obj.amount, amount: obj.amount,
precision: obj.precision, precision: obj.precision,
currency: String(obj.currency), currency_code: String(obj.currency),
}; };
} }

View File

@ -1,57 +1,160 @@
import Joi from "joi"; import Joi from "joi";
import { isNull } from "lodash";
import { NullOr } from "../../../../utilities";
import { RuleValidator } from "../RuleValidator"; import { RuleValidator } from "../RuleValidator";
import { import { INullableValueObjectOptions, NullableValueObject } from "./NullableValueObject";
INullableValueObjectOptions,
NullableValueObject,
} from "./NullableValueObject";
import { Result } from "./Result"; import { Result } from "./Result";
export class Percentage extends NullableValueObject<number> { export interface IPercentageOptions extends INullableValueObjectOptions {}
private static readonly MIN_VALUE = 0;
private static readonly MAX_VALUE = 100; export interface IPercentageProps {
amount: NullOr<number | string>;
precision?: number;
}
interface IPercentage {
amount: NullOr<number>;
precision: number;
}
export interface PercentageObject {
amount: number;
precision: number;
}
const defaultPercentageProps = {
amount: 0,
precision: 0,
};
export class Percentage extends NullableValueObject<IPercentage> {
public static readonly DEFAULT_PRECISION = 2;
public static readonly MIN_VALUE = 0;
public static readonly MAX_VALUE = 100;
private readonly _isNull: boolean;
private readonly _options: IPercentageOptions;
protected static validate(value: NullOr<number | string>, options: IPercentageOptions) {
const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default(
defaultPercentageProps.amount
);
protected static validate(
value: number,
options: INullableValueObjectOptions,
) {
const rule = Joi.number() const rule = Joi.number()
.min(Percentage.MIN_VALUE) .min(Percentage.MIN_VALUE)
.max(Percentage.MAX_VALUE) .max(Percentage.MAX_VALUE)
.label(options.label ? options.label : "value"); .label(options.label ? options.label : "percentage");
return RuleValidator.validate(rule, value); const rules = Joi.alternatives(ruleNull, rule);
return RuleValidator.validate<NullOr<number>>(rules, value);
} }
public static create( public static create(
value: number, props: IPercentageProps = defaultPercentageProps,
options: INullableValueObjectOptions = {}, options: IPercentageOptions = {}
) { ) {
if (props === null) {
throw new Error(`InvalidParams: props params is missing`);
}
const { amount = defaultPercentageProps.amount, precision = defaultPercentageProps.precision } =
props;
const _options = { const _options = {
label: "percentage", label: "percentage",
...options, ...options,
}; };
const validationResult = Percentage.validate(value, _options); const validationResult = Percentage.validate(amount, _options);
if (validationResult.isFailure) { if (validationResult.isFailure) {
return Result.fail(validationResult.error); return Result.fail(validationResult.error);
} }
return Result.ok<Percentage>(new Percentage(value)); let _amount: NullOr<number> = Percentage.sanitize(validationResult.object);
const _props = {
amount: isNull(_amount) ? 0 : _amount,
precision,
};
return Result.ok<Percentage>(new this(_props, isNull(_amount), options));
} }
private static sanitize(value: NullOr<number | string>): NullOr<number> {
let _value: NullOr<number> = null;
if (typeof value === "string") {
_value = parseInt(value, 10);
} else {
_value = value;
}
return _value;
}
constructor(percentage: IPercentage, isNull: boolean, options: IPercentageOptions) {
super(percentage);
this._isNull = Object.freeze(isNull);
this._options = Object.freeze(options);
}
get amount(): NullOr<number> {
return this.isNull() ? null : Number(this.props?.amount);
}
get precision(): number {
return this.isNull() ? 0 : Number(this.props?.precision);
}
public getAmount(): NullOr<number> {
return this.isNull() ? null : Number(this.props?.amount);
}
public getPrecision(): number {
return this.isNull() ? 0 : Number(this.props?.precision);
}
public isEmpty = (): boolean => {
return this.isNull();
};
public isNull = (): boolean => {
return this._isNull;
};
public toNumber(): number { public toNumber(): number {
return this.isNull() ? 0 : Number(this.value); if (this.isNull()) {
return 0;
}
const factor = Math.pow(10, this.precision);
const amount = Number(this.amount) / factor;
return Number(amount.toFixed(this.precision));
} }
public toString(): string { public toString(): string {
return this.isNull() ? "" : String(this.value); return this.isNull() ? "" : String(this.toNumber());
} }
public toPrimitive(): number { public toPrimitive(): number {
return this.toNumber(); return this.toNumber();
} }
}
export class InvalidPercentageError extends Error {} public toPrimitives() {
return this.toObject();
}
public toObject(): PercentageObject {
return {
amount: this.amount ? this.amount : 0,
precision: this.precision,
};
}
public hasSamePrecision(quantity: Percentage) {
return this.precision === quantity.precision;
}
}

View File

@ -17,17 +17,26 @@ interface IQuantity {
precision: number; precision: number;
} }
export interface QuantityObject {
amount: number;
precision: number;
}
const defaultQuantityProps = { const defaultQuantityProps = {
amount: 1, amount: 0,
precision: 0, precision: 0,
}; };
export class Quantity extends NullableValueObject<IQuantity> { export class Quantity extends NullableValueObject<IQuantity> {
public static readonly DEFAULT_PRECISION = defaultQuantityProps.precision;
private readonly _isNull: boolean; private readonly _isNull: boolean;
private readonly _options: IQuantityOptions; private readonly _options: IQuantityOptions;
protected static validate(value: NullOr<number | string>, options: IQuantityOptions = {}) { protected static validate(value: NullOr<number | string>, options: IQuantityOptions = {}) {
const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default(null); const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default(
defaultQuantityProps.amount
);
const ruleNumber = RuleValidator.RULE_IS_TYPE_NUMBER.label( const ruleNumber = RuleValidator.RULE_IS_TYPE_NUMBER.label(
options.label ? options.label : "quantity" options.label ? options.label : "quantity"
@ -100,6 +109,14 @@ export class Quantity extends NullableValueObject<IQuantity> {
return this.isNull() ? 0 : Number(this.props?.precision); return this.isNull() ? 0 : Number(this.props?.precision);
} }
public getAmount(): NullOr<number> {
return this.isNull() ? null : Number(this.props?.amount);
}
public getPrecision(): number {
return this.isNull() ? 0 : Number(this.props?.precision);
}
public isEmpty = (): boolean => { public isEmpty = (): boolean => {
return this.isNull(); return this.isNull();
}; };
@ -130,9 +147,9 @@ export class Quantity extends NullableValueObject<IQuantity> {
return this.toObject(); return this.toObject();
} }
public toObject(): IQuantityProps { public toObject(): QuantityObject {
return { return {
amount: this.amount, amount: this.amount ? this.amount : 0,
precision: this.precision, precision: this.precision,
}; };
} }

View File

@ -1,7 +1,7 @@
export * from "./Address"; export * from "./Address";
export * from "./AggregateRoot"; export * from "./AggregateRoot";
export * from "./Collection"; export * from "./Collection";
export * from "./Currency"; export * from "./CurrencyData";
export * from "./Description"; export * from "./Description";
export * from "./Email"; export * from "./Email";
export * from "./Entity"; export * from "./Entity";
@ -19,11 +19,11 @@ export * from "./Result";
export * from "./ResultCollection"; export * from "./ResultCollection";
export * from "./Slug"; export * from "./Slug";
export * from "./StringValueObject"; export * from "./StringValueObject";
export * from "./TINNumber";
export * from "./TextValueObject"; export * from "./TextValueObject";
export * from "./UTCDateValue"; export * from "./TINNumber";
export * from "./UniqueID"; export * from "./UniqueID";
export * from "./UnitPrice"; //export * from "./UnitPrice";
export * from "./UTCDateValue";
export * from "./ValueObject"; export * from "./ValueObject";
export * from "./QueryCriteria"; export * from "./QueryCriteria";

View File

@ -1,5 +1,11 @@
import Joi from "joi"; import Joi from "joi";
import { IMoney_Request_DTO, Result, RuleValidator } from "../../../../../common"; import {
IMoney_Response_DTO,
IPercentage_Response_DTO,
IQuantity_Response_DTO,
Result,
RuleValidator,
} from "../../../../../common";
export interface ICreateQuote_Request_DTO { export interface ICreateQuote_Request_DTO {
id: string; id: string;
@ -13,14 +19,23 @@ export interface ICreateQuote_Request_DTO {
notes: string; notes: string;
validity: string; validity: string;
subtotal: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total: IMoney_Response_DTO;
items: ICreateQuoteItem_Request_DTO[]; items: ICreateQuoteItem_Request_DTO[];
dealer_id: string;
} }
export interface ICreateQuoteItem_Request_DTO { export interface ICreateQuoteItem_Request_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string; description: string;
quantity: string; unit_price: IMoney_Response_DTO;
unit_measure: string; price: IMoney_Response_DTO;
unit_price: IMoney_Request_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
} }
export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Request_DTO) { export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Request_DTO) {
@ -37,14 +52,21 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
items: Joi.array().items( items: Joi.array().items(
Joi.object({ Joi.object({
article_id: Joi.string(),
description: Joi.string(), description: Joi.string(),
quantity: Joi.string(), quantity: {
unit_measure: Joi.string(), amount: Joi.number(),
precision: Joi.number(),
},
unit_price: Joi.object({ unit_price: Joi.object({
amount: Joi.number(), amount: Joi.number(),
precision: Joi.number(), precision: Joi.number(),
currency: Joi.string(), currency: Joi.string(),
}), }),
discount: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
}).unknown(true) }).unknown(true)
), ),
}).unknown(true); }).unknown(true);

View File

@ -1,23 +1,31 @@
import { IMoney_Response_DTO } from "../../../../../common"; import {
IMoney_Response_DTO,
IPercentage_Response_DTO,
IQuantity_Response_DTO,
} from "../../../../../common";
export interface ICreateQuote_Response_DTO { export interface ICreateQuote_Response_DTO {
id: string; id: string;
status: string; status: string;
date: string; date: string;
reference: string;
customer_information: string;
lang_code: string; lang_code: string;
currency_code: string; currency_code: string;
payment_method: string;
notes: string;
validity: string;
subtotal: IMoney_Response_DTO; discount: IPercentage_Response_DTO;
total: IMoney_Response_DTO;
items: ICreateQuote_QuoteItem_Response_DTO[]; items: ICreateQuote_QuoteItem_Response_DTO[];
} }
export interface ICreateQuote_QuoteItem_Response_DTO { export interface ICreateQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string; description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO; unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO; price: IMoney_Response_DTO;
total: IMoney_Response_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
} }

View File

@ -1,4 +1,8 @@
import { IMoney_Response_DTO } from "../../../../../common"; import {
IMoney_Response_DTO,
IPercentage_Response_DTO,
IQuantity_Response_DTO,
} from "../../../../../common";
export interface IGetQuote_Response_DTO { export interface IGetQuote_Response_DTO {
id: string; id: string;
@ -8,21 +12,26 @@ export interface IGetQuote_Response_DTO {
customer_information: string; customer_information: string;
lang_code: string; lang_code: string;
currency_code: string; currency_code: string;
payment_method: string; payment_method: string;
notes: string; notes: string;
validity: string; validity: string;
subtotal: IMoney_Response_DTO; subtotal_price: IMoney_Response_DTO;
total: IMoney_Response_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
items: IGetQuote_QuoteItem_Response_DTO[]; items: IGetQuote_QuoteItem_Response_DTO[];
dealer_id: string;
} }
export interface IGetQuote_QuoteItem_Response_DTO { export interface IGetQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string; description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO; unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO; subtotal_price: IMoney_Response_DTO;
total: IMoney_Response_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
} }

View File

@ -1,4 +1,4 @@
import { IMoney_Response_DTO } from "../../../../../common"; import { IQuantuty_Response_DTO } from "../../../../../common";
export interface IListQuotes_Response_DTO { export interface IListQuotes_Response_DTO {
id: string; id: string;
@ -9,6 +9,6 @@ export interface IListQuotes_Response_DTO {
lang_code: string; lang_code: string;
currency_code: string; currency_code: string;
subtotal: IMoney_Response_DTO; subtotal: IQuantuty_Response_DTO;
total: IMoney_Response_DTO; total: IQuantuty_Response_DTO;
} }

View File

@ -1,5 +1,13 @@
import Joi from "joi"; import Joi from "joi";
import { IMoney_Request_DTO, Result, RuleValidator } from "../../../../../common"; import {
IMoney_Request_DTO,
IMoney_Response_DTO,
IPercentage_Request_DTO,
IPercentage_Response_DTO,
IQuantity_Response_DTO,
Result,
RuleValidator,
} from "../../../../../common";
export interface IUpdateQuote_Request_DTO { export interface IUpdateQuote_Request_DTO {
status: string; status: string;
@ -12,37 +20,71 @@ export interface IUpdateQuote_Request_DTO {
notes: string; notes: string;
validity: string; validity: string;
subtotal: IMoney_Request_DTO;
discount: IPercentage_Request_DTO;
items: IUpdateQuoteItem_Request_DTO[]; items: IUpdateQuoteItem_Request_DTO[];
} }
export interface IUpdateQuoteItem_Request_DTO { export interface IUpdateQuoteItem_Request_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string; description: string;
quantity: string; unit_price: IMoney_Response_DTO;
unit_measure: string; subtotal_price: IMoney_Response_DTO;
unit_price: IMoney_Request_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
} }
export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Request_DTO) { export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Request_DTO) {
const schema = Joi.object({ const schema = Joi.object({
status: Joi.string(),
date: Joi.string(), date: Joi.string(),
reference: Joi.string(), reference: Joi.string(),
lang_code: Joi.string(),
customer_information: Joi.string(), customer_information: Joi.string(),
lang_code: Joi.string(),
currency_code: Joi.string(), currency_code: Joi.string(),
payment_method: Joi.string(), payment_method: Joi.string(),
notes: Joi.string(), notes: Joi.string(),
validity: Joi.string(), validity: Joi.string(),
subtotal: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
currency: Joi.string(),
}),
discount: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
items: Joi.array().items( items: Joi.array().items(
Joi.object({ Joi.object({
article_id: Joi.string(),
quantity: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
description: Joi.string(), description: Joi.string(),
quantity: Joi.string(),
unit_measure: Joi.string(),
unit_price: Joi.object({ unit_price: Joi.object({
amount: Joi.number(), amount: Joi.number(),
precision: Joi.number(), precision: Joi.number(),
currency: Joi.string(), currency: Joi.string(),
}), }),
subtotal_price: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
currency: Joi.string(),
}),
discount: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
total_price: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
currency: Joi.string(),
}),
}).unknown(true) }).unknown(true)
), ),
}).unknown(true); }).unknown(true);

View File

@ -1,23 +1,33 @@
import { IMoney_Response_DTO } from "shared/lib/contexts/common"; import { IMoney_Response_DTO } from "shared/lib/contexts/common";
import { IPercentage_Response_DTO, IQuantity_Response_DTO } from "../../../../../common";
export interface IUpdateQuote_Response_DTO { export interface IUpdateQuote_Response_DTO {
id: string; id: string;
status: string; status: string;
date: string; date: string;
language_code: string; reference: string;
customer_information: string;
lang_code: string;
currency_code: string; currency_code: string;
payment_method: string;
notes: string;
validity: string;
subtotal: IMoney_Response_DTO; subtotal: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total: IMoney_Response_DTO; total: IMoney_Response_DTO;
items: IUpdateQuote_QuoteItem_Response_DTO[]; items: IUpdateQuote_QuoteItem_Response_DTO[];
dealer_id: string;
} }
export interface IUpdateQuote_QuoteItem_Response_DTO { export interface IUpdateQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string; description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO; unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO; price: IMoney_Response_DTO;
total: IMoney_Response_DTO; discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
} }