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 = {
$schema: "https://json.schemastore.org/eslintrc",
root: true,
env: { browser: true, es2020: true },
extends: [
@ -12,5 +13,6 @@ module.exports = {
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"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 { Routes } from "./Routes";
import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosDataProvider } from "./lib/axios";
import { createAxiosAuthActions } from "./lib/axios/createAxiosAuthActions";
import { DataSourceProvider } from "./lib/hooks/useDataSource/DataSourceContext";
import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
import { DataSourceProvider } from "./lib/hooks";
function App() {
const queryClient = new QueryClient({
@ -28,6 +27,7 @@ function App() {
<TooltipProvider delayDuration={0}>
<Suspense fallback={<LoadingOverlay />}>
<Routes />
<ToastContainer />
</Suspense>
</TooltipProvider>

View File

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

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,
});
const columns = useMemo<ColumnDef<IListArticles_Response_DTO, any>[]>(
const columns = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(
() => [
{
id: "id" as const,
@ -65,7 +65,7 @@ export const CatalogDataTable = () => {
id: "retail_price" as const,
accessorKey: "retail_price",
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;
return <div className='text-right'>{price.toFormat()}</div>;
},
@ -113,10 +113,8 @@ export const CatalogDataTable = () => {
}
return (
<>
<DataTable table={table} paginationOptions={{ visible: true }}>
<DataTableToolbar table={table} />
</DataTable>
</>
<DataTable table={table} paginationOptions={{ visible: true }} title='Catálogo'>
<DataTableToolbar table={table} />
</DataTable>
);
};

View File

@ -32,6 +32,7 @@ import {
PaginationItem,
Progress,
Separator,
Skeleton,
Table,
TableBody,
TableCell,
@ -44,20 +45,24 @@ import {
TabsTrigger,
} from "@/ui";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
export const DashboardPage = () => {
const { data, status } = useGetIdentity();
const navigate = useNavigate();
const { data: userIdentity, status } = useGetIdentity();
return (
<Layout>
<LayoutHeader />
<LayoutContent>
{status === "success" && (
{status === "success" ? (
<div className='flex items-center'>
<h1 className='text-lg font-semibold md:text-2xl'>{`${t("dashboard.welcome")}, ${
data?.name
userIdentity?.name
}`}</h1>
</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'>
@ -72,7 +77,9 @@ export const DashboardPage = () => {
</CardDescription>
</CardHeader>
<CardFooter>
<Button>Crear nueva cotización</Button>
<Button onClick={() => navigate("/quotes/add")}>
{t("quotes.create.title")}
</Button>
</CardFooter>
</Card>
<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 [activeId, setActiveId] = useState<UniqueIdentifier | null>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
@ -145,8 +146,8 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (originalRow: unknown) => originalRow?.id,
debugHeaders: true,
debugColumns: true,
debugHeaders: false,
debugColumns: false,
meta: {
insertItem: (rowIndex: number, data: object = {}) => {
actions.insert(rowIndex, data, { shouldFocus: true });
@ -263,16 +264,43 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
const hadleNewItem = useCallback(() => {
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]);

View File

@ -1,20 +1,21 @@
import {
ButtonGroup,
CancelButton,
FormGroup,
FormMoneyField,
FormPercentageField,
FormQuantityField,
FormTextAreaField,
FormTextField,
SubmitButton,
} from "@/components";
import { Input } from "@/ui";
import { t } from "i18next";
import { DataTableProvider } from "@/lib/hooks";
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 { useDetailColumns } from "../../hooks";
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
import { SortableDataTable } from "../SortableDataTable";
export const QuoteDetailsCardEditor = () => {
const { control, register, formState } = useFormContext();
const { control, register, watch, getValues, setValue } = useFormContext();
const { fields, ...fieldActions } = useFieldArray({
control,
@ -39,27 +40,16 @@ export const QuoteDetailsCardEditor = () => {
accessorKey: "quantity",
header: "quantity",
size: 5,
cell: ({ row: { index }, column: { id } }) => {
return (
<FormTextField
type='number'
control={control}
{...register(`items.${index}.quantity`)}
/>
);
cell: ({ row: { index } }) => {
return <FormQuantityField {...register(`items.${index}.quantity`)} />;
},
},
{
id: "description" as const,
accessorKey: "description",
cell: ({ row: { index }, column: { id } }) => {
return (
<FormTextAreaField
autoSize
control={control}
{...register(`items.${index}.description`)}
/>
);
header: "description",
cell: ({ row: { index } }) => {
return <FormTextAreaField autoSize {...register(`items.${index}.description`)} />;
},
},
@ -69,7 +59,7 @@ export const QuoteDetailsCardEditor = () => {
header: "retail_price",
size: 10,
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",
size: 10,
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",
size: 5,
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",
size: 10,
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 (
<FormGroup
title={t("quotes.create.tabs.items.title")}
description={t("quotes.create.tabs.items.desc")}
actions={
<ButtonGroup className='md:hidden'>
<CancelButton onClick={() => null} size='sm' />
<SubmitButton
disabled={!formState.isDirty || formState.isSubmitting || formState.isLoading}
label='Guardar'
size='sm'
/>
</ButtonGroup>
}
<ResizablePanelGroup
direction='horizontal'
autoSaveId='uecko.quotes.details_layout'
className='items-stretch h-full'
>
<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} />
</div>
</FormGroup>
</ResizablePanel>
<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 { SubmitButton } from "@/components";
import { useWarnAboutChange } from "@/lib/hooks";
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 { useNavigate } from "react-router-dom";
import { useQuotes } from "./hooks";
type QuoteDataForm = {
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;
};*/
interface QuoteDataForm extends ICreateQuote_Request_DTO {}
export const QuoteCreate = () => {
//const [loading, setLoading] = useState(false);
//const { data: userIdentity } = useGetIdentity();
//console.log(userIdentity);
const navigate = useNavigate();
const { useMutation } = useQuotes();
const { mutate } = useMutation();
const { setWarnWhen } = useWarnAboutChange();
const { useCreate } = useQuotes();
const { mutate } = useCreate();
const form = useForm<QuoteDataForm>({
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) => {
alert(JSON.stringify(formData));
console.log(JSON.stringify(formData));
try {
//setLoading(true);
setWarnWhen(false);
mutate(formData, {
onSuccess: (data) => {
navigate(`/quotes/edit/${data.id}`, { relative: "path", replace: true });
@ -73,10 +68,15 @@ export const QuoteCreate = () => {
return (
<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='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' />
<span className='sr-only'>{t("quotes.common.back")}</span>
</Button>
@ -85,7 +85,7 @@ export const QuoteCreate = () => {
</h1>
</div>
<div className='grid max-w-lg gap-6'>
<div className='grid w-6/12 gap-6 mx-auto'>
<FormTextField
className='row-span-2'
name='reference'
@ -112,12 +112,17 @@ export const QuoteCreate = () => {
description={t("quotes.create.form_fields.customer_information.desc")}
placeholder={t("quotes.create.form_fields.customer_information.placeholder")}
/>
</div>
<div className='flex items-center justify-start gap-2'>
<BackHistoryButton size='sm' label={t("quotes.create.buttons.discard")} url='/quotes' />
<div className='flex items-center justify-around gap-2'>
<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>
</form>

View File

@ -1,21 +1,23 @@
import { ChevronLeft } from "lucide-react";
import { SubmitButton } from "@/components";
import { FormMoneyField, LoadingOverlay, SubmitButton } from "@/components";
import { calculateItemTotals } from "@/lib/calc";
import { useGetIdentity } from "@/lib/hooks";
import { useUrlId } from "@/lib/hooks/useUrlId";
import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui";
import { IUpdateQuote_Request_DTO, MoneyValue } from "@shared/contexts";
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 {
QuoteDetailsCardEditor,
QuoteDocumentsCardEditor,
QuoteGeneralCardEditor,
} from "./components/editors";
import { QuoteDetailsCardEditor, QuoteGeneralCardEditor } from "./components/editors";
import { useQuotes } from "./hooks";
type QuoteDataForm = {
id: string;
status: string;
// simple typesafe helperfunction
type EndsWith<T, b extends string> = T extends `${infer f}${b}` ? T : never;
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;
reference: string;
customer_information: string;
@ -24,23 +26,31 @@ type QuoteDataForm = {
payment_method: string;
notes: string;
validity: string;
items: any[];
};
discount: IPercentage;
type QuoteCreateProps = {
isOverModal?: boolean;
};
subtotal: IMoney;
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 quoteId = useUrlId();
const { data: userIdentity } = useGetIdentity();
console.log(userIdentity);
const { useQuery, useMutation } = useQuotes();
const { useOne, useUpdate } = useQuotes();
const { data } = useQuery;
const { mutate } = useMutation;
const { data, status } = useOne(quoteId);
const { mutate } = useUpdate(quoteId);
const form = useForm<QuoteDataForm>({
mode: "onBlur",
@ -54,53 +64,119 @@ export const QuoteEdit = ({ isOverModal }: QuoteCreateProps) => {
payment_method: "",
notes: "",
validity: "",
subtotal: "",
items: [],
},
});
const onSubmit: SubmitHandler<QuoteDataForm> = async (data) => {
alert(JSON.stringify(data));
console.debug(JSON.stringify(data));
try {
setLoading(true);
data.currency_code = "EUR";
data.lang_code = String(userIdentity?.language);
mutate(data);
// Transformación del form -> typo de request
mutate(data, {
onError: (error) => {
alert(error);
},
//onSettled: () => {},
onSuccess: () => {
alert("guardado");
},
});
} finally {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'>
<div className='flex items-center gap-4'>
<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>
</Button>
<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>
<Badge variant='default' className='ml-auto sm:ml-0'>
{t("quotes.status.draft")}
{data.status}
</Badge>
<div className='items-center hidden gap-2 md:ml-auto md:flex'>
<Button variant='outline' size='sm'>
{t("quotes.create.buttons.discard")}
{t("common.cancel")}
</Button>
<SubmitButton variant={form.formState.isDirty ? "default" : "outline"} size='sm'>
{t("quotes.create.buttons.save_quote")}
{t("common.save")}
</SubmitButton>
</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>
<TabsTrigger value='general'>{t("quotes.create.tabs.general")}</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>
</TabsList>
<TabsContent value='general'>
@ -110,9 +186,6 @@ export const QuoteEdit = ({ isOverModal }: QuoteCreateProps) => {
<QuoteDetailsCardEditor />
</TabsContent>
<TabsContent value='documents'>
<QuoteDocumentsCardEditor />
</TabsContent>
<TabsContent value='history'></TabsContent>
</Tabs>
<div className='flex items-center justify-center gap-2 md:hidden'>

View File

@ -6,6 +6,8 @@ import {
ICreateQuote_Request_DTO,
ICreateQuote_Response_DTO,
IGetQuote_Response_DTO,
IUpdateQuote_Request_DTO,
IUpdateQuote_Response_DTO,
UniqueID,
} from "@shared/contexts";
@ -14,22 +16,23 @@ export type UseQuotesGetParamsType = {
queryOptions?: Record<string, unknown>;
};
export const useQuotes = (params?: UseQuotesGetParamsType) => {
export const useQuotes = () => {
const dataSource = useDataSource();
const keys = useQueryKey();
return {
useQuery: () =>
useOne: (id?: string, params?: UseQuotesGetParamsType) =>
useOne<IGetQuote_Response_DTO>({
queryKey: keys().data().resource("quotes").action("one").id("").params().get(),
queryFn: () =>
dataSource.getOne({
resource: "quotes",
id: "",
id: String(id),
}),
enabled: !!id,
...params,
}),
useMutation: () =>
useCreate: () =>
useSave<ICreateQuote_Response_DTO, TDataSourceError, ICreateQuote_Request_DTO>({
mutationKey: keys().data().resource("quotes").action("one").id("").params().get(),
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 { PropsWithChildren, ReactNode } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Separator,
Table,
TableBody,
TableCaption,
@ -8,11 +16,9 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/ui/table";
import { PropsWithChildren, ReactNode } from "react";
} from "@/ui";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardFooter, CardHeader } from "@/ui";
import { DataTableColumnHeader } from "./DataTableColumnHeader";
import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination";
@ -23,38 +29,58 @@ export type DataTablePaginationOptionsProps<TData> = Pick<
"visible"
>;
export type DataTableHeaderOptionsProps = {
visible: boolean;
};
export type DataTableProps<TData> = PropsWithChildren<{
table: ReactTable<TData>;
title?: ReactNode;
description?: ReactNode;
caption?: ReactNode;
paginationOptions?: DataTablePaginationOptionsProps<TData>;
headerOptions?: DataTableHeaderOptionsProps;
className?: string;
rowClassName?: string;
cellClassName?: string;
}>;
export function DataTable<TData>({
table,
title,
description,
caption,
paginationOptions,
headerOptions = { visible: true },
children,
className,
...props
rowClassName,
cellClassName,
}: DataTableProps<TData>) {
const headerVisible = headerOptions?.visible;
return (
<>
<Card>
<Card className={className}>
{(title || description) && (
<CardHeader className='pb-0'>
<CardDescription
className={cn("w-full space-y-2.5 overflow-auto mt-7", className)}
{...props}
>
{children}
</CardDescription>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className='pt-6'>
<Table>
{typeof caption !== "undefined" && <TableCaption>{caption}</TableCaption>}
)}
<CardContent className='pt-6'>
{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>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className={rowClassName}>
{headerGroup.headers.map((header) => {
return (
<TableHead
@ -69,35 +95,42 @@ export function DataTable<TData>({
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns.length} className='h-24 text-center'>
No hay datos para mostrar
</TableCell>
)}
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={rowClassName}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={cellClassName}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<DataTablePagination
className='flex-1'
visible={paginationOptions?.visible}
table={table}
/>
</CardFooter>
</Card>
</>
))
) : (
<TableRow className={rowClassName}>
<TableCell
className={cn("h-24 text-center", cellClassName)}
colSpan={table.getAllColumns.length}
>
No hay datos para mostrar
</TableCell>
</TableRow>
)}
</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
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
)}
>

View File

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

View File

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

View File

@ -11,10 +11,12 @@ import { DataTableColumnOptions } from "./DataTableColumnOptions";
interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>;
filterFields?: DataTableFilterField<TData>[];
fullWidthFilter?: boolean;
}
export function DataTableToolbar<TData>({
table,
fullWidthFilter,
className,
children,
...props
@ -35,7 +37,7 @@ export function DataTableToolbar<TData>({
placeholder={t("common.filter_placeholder")}
value={globalFilter}
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 && (

View File

@ -1,126 +1,136 @@
import { cn } from "@/lib/utils";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@/ui";
import { FormControl, FormDescription, 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";
type FormMoneyFieldProps = Omit<FormTextFieldProps, "type">;
type FormMoneyFieldProps = Omit<FormTextFieldProps, "type"> & {
defaultValue?: any;
};
// Spanish currency config
const moneyFormatter = Intl.NumberFormat("es-ES", {
currency: "EUR",
currencyDisplay: "symbol",
currencySign: "standard",
style: "currency",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function FormMoneyField({
label,
placeholder,
hint,
description,
required,
className,
leadIcon,
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);
}*/
export const FormMoneyField = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormMoneyFieldProps
>((props, ref) => {
const {
label,
placeholder,
hint,
description,
required,
className,
leadIcon,
trailIcon,
button,
disabled,
name,
defaultValue,
} = props;
//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 = {
input: (value: any) =>
isNaN(value) || value === 0 ? "" : moneyFormatter.format(value),
input: (value: IMoney) => {
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>) => {
const output = parseInt(event.target.value, 10);
return isNaN(output) ? 0 : output;
const output = parseFloat(event.target.value);
const moneyOrError = MoneyValue.create({
amount: output * Math.pow(10, precision),
precision,
currencyCode,
});
if (moneyOrError.isFailure) {
throw moneyOrError.error;
}
return moneyOrError.object.toObject();
},
};
return (
<FormField
<Controller
defaultValue={defaultValue}
control={control}
name={name}
rules={{ required }}
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => {
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} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(
leadIcon
? "relative flex items-stretch flex-grow focus-within:z-10"
: "",
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">
<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,
null
)}
</div>
)}
<FormControl
className={cn(
"block",
leadIcon ? "pl-10" : "",
trailIcon ? "pr-10" : "",
)}
className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
>
<Input
type="text"
placeholder={placeholder}
disabled={disabled}
className={cn(
fieldState.error ? "border-destructive focus-visible:ring-destructive" : ""
)}
{...field}
onChange={(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">
<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,
null
)}
</div>
)}
@ -129,9 +139,9 @@ export function FormMoneyField({
</div>
{description && <FormDescription>{description}</FormDescription>}
<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 "./FormLabel";
export * from "./FormMoneyField";
export * from "./FormPercentageField";
export * from "./FormQuantityField";
export * from "./FormTextAreaField";
export * from "./FormTextField";

View File

@ -1,7 +1,11 @@
import { UnsavedChangesNotifier, UnsavedWarnProvider } from "@/lib/hooks";
import { PropsWithChildren } from "react";
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";

View File

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

View File

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

View File

@ -59,7 +59,7 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
break;
case 401:
console.error("UnAuthorized");
//return (window.location.href = "/logout");
return (window.location.href = "/logout");
break;
case 403:
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 "./useCustomDialog";
export * from "./useDataSource";
export * from "./useDataTable";
export * from "./useLocalization";
export * from "./usePagination";
export * from "./useTheme";
export * from "./useUnsavedChangesNotifier";

View File

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

View File

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

View File

@ -9,6 +9,7 @@ export const useIsLoggedIn = (queryOptions?: UseQueryOptions<AuthActionCheckResp
const result = useQuery<AuthActionCheckResponse>({
queryKey: keys().auth().action("check").get(),
queryFn: check,
retry: false,
...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 {
QueryFunction,
QueryKey,
UseQueryResult,
useQuery,
} from '@tanstack/react-query';
import { QueryFunction, QueryKey, UseQueryResult, useQuery } from "@tanstack/react-query";
export interface IUseManyQueryOptions<
TUseManyQueryData = unknown,
TUseManyQueryError = unknown
TUseManyQueryData = unknown,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TUseManyQueryError = unknown
> {
queryKey: QueryKey;
queryFn: QueryFunction<TUseManyQueryData, QueryKey>;
enabled?: boolean;
select?: (data: TUseManyQueryData) => TUseManyQueryData;
queryOptions?: any;
queryKey: QueryKey;
queryFn: QueryFunction<TUseManyQueryData, QueryKey>;
enabled?: boolean;
select?: (data: TUseManyQueryData) => TUseManyQueryData;
queryOptions?: any;
}
export function useMany<TUseManyQueryData, TUseManyQueryError>(
options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError>
options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError>
): UseQueryResult<TUseManyQueryData, TUseManyQueryError> {
const { queryKey, queryFn, enabled, select, queryOptions } = options;
const queryResponse = useQuery<TUseManyQueryData, TUseManyQueryError>({
queryKey,
queryFn,
keepPreviousData: true,
...queryOptions,
enabled,
select,
const { queryKey, queryFn, enabled, select, queryOptions } = options;
});
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<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
TUseRemoveMutationData,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TUseRemoveMutationError,
TUseRemoveMutationVariables
> {
mutationFn: (
variables: TUseRemoveMutationVariables,
) => Promise<TUseRemoveMutationData>;
mutationFn: (variables: TUseRemoveMutationVariables) => Promise<TUseRemoveMutationData>;
}
export function useRemove<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
>(
options: IUseRemoveMutationOptions<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables>(options: IUseRemoveMutationOptions<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
>) {
const { mutationFn, ...params } = options;
TUseRemoveMutationVariables
>
) {
const { mutationFn, ...params } = options;
return useMutation<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables>({
mutationFn,
...params
});
return useMutation<TUseRemoveMutationData, TUseRemoveMutationError, 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 { PropsWithChildren, createContext, useCallback, useMemo, useState } from "react";
import {
Dispatch,
PropsWithChildren,
SetStateAction,
createContext,
useCallback,
useMemo,
useState,
} from "react";
import { useSyncedPagination } from "./useSyncedPagination";
export interface IDataTableContextState {
pagination: PaginationState;
setPagination: (newPagination: PaginationState) => void;
sorting: [];
setSorting: () => void;
sorting: SortingState;
setSorting: Dispatch<SetStateAction<SortingState>>;
globalFilter: string;
setGlobalFilter: (newGlobalFilter: string) => void;
resetGlobalFilter: () => void;
@ -16,12 +25,14 @@ export interface IDataTableContextState {
export const DataTableContext = createContext<IDataTableContextState | null>(null);
export const DataTableProvider = ({
syncWithLocation = true,
initialGlobalFilter = "",
children,
}: PropsWithChildren<{
syncWithLocation?: boolean;
initialGlobalFilter?: string;
}>) => {
const [pagination, setPagination] = usePaginationParams();
const [pagination, setPagination] = useSyncedPagination(syncWithLocation);
const [globalFilter, setGlobalFilter] = useState<string>(initialGlobalFilter);
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 {
DataTableColumnProps,
getDataTableSelectionColumn,
} from "@/components";
import { DataTableColumnProps, getDataTableSelectionColumn } from "@/components";
import { IListResponse_DTO } from "@shared/contexts";
import {
OnChangeFn,
@ -12,12 +9,9 @@ import {
} from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { UseListQueryResult } from "../useDataSource";
import { usePaginationParams } from "../usePagination";
import { usePaginationSyncWithLocation } from "../usePagination";
type TUseDataTableQueryResult<TData, TError> = UseListQueryResult<
IListResponse_DTO<TData>,
TError
>;
type TUseDataTableQueryResult<TData, TError> = UseListQueryResult<IListResponse_DTO<TData>, TError>;
type TUseDataTableQuery<TData, TError> = (params: {
pagination: {
@ -44,16 +38,9 @@ type DataTableColumnsOptionsProps<TData, TValue> = {
columns: DataTableColumnsProps<TData, TValue>;
};
type DataTableColumnsProps<TData, TValue> = DataTableColumnProps<
TData,
TValue
>[];
type DataTableColumnsProps<TData, TValue> = DataTableColumnProps<TData, TValue>[];
export const useQueryDataTable = <
TData = unknown,
TValue = unknown,
TError = Error
>({
export const useQueryDataTable = <TData = unknown, TValue = unknown, TError = Error>({
fetchQuery,
enabled = true,
@ -64,7 +51,7 @@ export const useQueryDataTable = <
const defaultData = useMemo(() => [], []);
const [rowSelection, setRowSelection] = useState({});
const [pagination, setPagination] = usePaginationParams();
const [pagination, setPagination] = usePaginationSyncWithLocation();
const [sorting, setSorting] = useState<SortingState>([]);
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 */
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LocaleToCurrencyTable, rtlLangsList } from "./utils";
type UseLocalizationProps = {
@ -12,10 +11,10 @@ export const useLocalization = (props: UseLocalizationProps) => {
const { locale } = props;
const [lang, loc] = locale.split("-");
const { i18n } = useTranslation();
//const { i18n } = useTranslation();
// Obtener el idioma actual
const currentLanguage = i18n.language;
// const currentLanguage = i18n.language;
const formatCurrency = useCallback(
(value: number) => {

View File

@ -1,2 +1,2 @@
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 { usePagination } from "./usePagination";
export const usePaginationParams = (
export const usePaginationSyncWithLocation = (
initialPageIndex: number = INITIAL_PAGE_INDEX,
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 "./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 { useCustomDialog } from "../useCustomDialog";
import { CustomDialog } from "@/components";
import { t } from "i18next";
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { useWarnAboutChange } from "./useWarnAboutChange";
type UnsavedChangesNotifierProps = {
translationKey?: string;
message?: string;
};
export const UnsavedChangesNotifier: React.FC<UnsavedChangesNotifierProps> = () => {
const { openDialog: openWarmDialog, DialogComponent: WarmDialog } = useCustomDialog({
title: "Hay cambios sin guardar",
description: "Are you sure you want to leave? You have unsaved changes.",
export const UnsavedChangesNotifier = ({
message = t("unsaved_changes_prompt"),
}: UnsavedChangesNotifierProps) => {
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_tooltip": "Duplica las fila(s) seleccionadas(s)",
"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": {
"home": "Inicio",
@ -146,6 +147,9 @@
"desc": "desc"
}
}
},
"edit": {
"title": "Cotización"
}
},
"settings": {

View File

@ -15,6 +15,7 @@ export const useAutosizeTextArea = ({
minHeight = 0,
}: UseAutosizeTextAreaProps) => {
const [init, setInit] = React.useState(true);
React.useEffect(() => {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
const offsetBorder = 2;
@ -36,5 +37,5 @@ export const useAutosizeTextArea = ({
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 {
Collection,
Currency,
CurrencyData,
Description,
DomainError,
ICreateQuote_Request_DTO,
IDomainError,
Language,
Note,
Percentage,
Quantity,
Result,
UTCDateValue,
@ -153,7 +154,7 @@ export class CreateQuoteUseCase
return Result.fail(customerOrError.error);
}
const currencyOrError = Currency.createFromCode(quoteDTO.currency_code);
const currencyOrError = CurrencyData.createFromCode(quoteDTO.currency_code);
if (currencyOrError.isFailure) {
return Result.fail(currencyOrError.error);
}
@ -177,13 +178,15 @@ export class CreateQuoteUseCase
quoteDTO.items?.map(
(item) =>
QuoteItem.create({
articleId: item.article_id,
description: Description.create(item.description).object,
quantity: Quantity.create({ amount: item.quantity, precision: 4 }).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
currencyCode: item.unit_price.currency_code,
precision: item.unit_price.precision,
}).object,
discount: Percentage.create(item.discount.amount).object,
}).object
)
);

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import {
AggregateRoot,
Currency,
CurrencyData,
ICollection,
IDomainError,
Language,
MoneyValue,
Note,
Percentage,
Result,
UTCDateValue,
UniqueID,
@ -20,13 +22,17 @@ export interface IQuoteProps {
reference: QuoteReference;
customer: QuoteCustomer;
language: Language;
currency: Currency;
currency: CurrencyData;
paymentMethod: Note;
notes: Note;
validity: Note;
items: ICollection<QuoteItem>;
//subtotalPrice: MoneyValue;
discount: Percentage;
//totalPrice: MoneyValue;
dealerId: UniqueID;
}
@ -38,10 +44,15 @@ export interface IQuote {
reference: QuoteReference;
customer: QuoteCustomer;
language: Language;
currency: Currency;
currency: CurrencyData;
paymentMethod: Note;
notes: Note;
validity: Note;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
items: ICollection<QuoteItem>;
dealerId: UniqueID;
@ -60,6 +71,14 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
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) {
super(props, id);
@ -113,4 +132,16 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
get 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,
IEntityProps,
MoneyValue,
Percentage,
Quantity,
Result,
UniqueID,
} from "@shared/contexts";
export interface IQuoteItemProps extends IEntityProps {
articleId: string;
description: Description; // Descripción del artículo o servicio
quantity: Quantity; // Cantidad de unidades
unitPrice: MoneyValue; // Precio unitario en la moneda de la factura
// subtotalPrice: MoneyValue; // Precio unitario * Cantidad
discount: Percentage; // % descuento
// totalPrice: MoneyValue;
}
export interface IQuoteItem {
articleId: string;
description: Description;
quantity: Quantity;
unitPrice: MoneyValue;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
}
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));
}
get articleId(): string {
return this.props.articleId;
}
get description(): Description {
return this.props.description;
}
@ -37,4 +50,16 @@ export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem {
get unitPrice(): MoneyValue {
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 { ISalesContext } from "../../../../../Sales.context";
@ -13,44 +17,38 @@ export const GetQuotePresenter: IGetQuotePresenter = {
id: quote.id.toString(),
status: quote.status.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(),
subtotal: {
amount: 0,
precision: 2,
currency: "EUR",
},
total: {
amount: 0,
precision: 2,
currency: "EUR",
},
payment_method: quote.paymentMethod.toString(),
validity: quote.validity.toString(),
notes: quote.notes.toString(),
subtotal_price: quote.subtotalPrice.toObject(),
discount: quote.discount.toObject(),
total_price: quote.totalPrice.toObject(),
items: quoteItemPresenter(quote.items, context),
dealer_id: quote.dealerId.toString(),
};
},
};
// 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.items.map((item: QuoteItem) => ({
article_id: item.articleId,
description: item.description.toString(),
quantity: item.quantity.toString(),
unit_measure: "",
unit_price: {
amount: 0,
precision: 2,
currency: "EUR",
},
subtotal: {
amount: 0,
precision: 2,
currency: "EUR",
},
total: {
amount: 0,
precision: 2,
currency: "EUR",
},
quantity: item.quantity.toObject(),
unit_price: item.unitPrice.toObject(),
subtotal_price: item.subtotalPrice.toObject(),
discount: item.discount.toObject(),
total_price: item.totalPrice.toObject(),
}))
: [];

View File

@ -1,5 +1,6 @@
import { ListQuotesUseCase } from "@/contexts/sales/application";
import { registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import Express from "express";
import { ListQuotesController } from "./ListQuotes.controller";
import { ListQuotesPresenter } from "./presenter";
@ -9,7 +10,7 @@ export const listQuotesController = (
res: Express.Response,
next: Express.NextFunction
) => {
const context = res.locals.context;
const context: ISalesContext = res.locals.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 { IQuoteProps, Quote, QuoteCustomer, QuoteReference } from "../../domain";
@ -33,18 +33,41 @@ class QuoteMapper
const props: IQuoteProps = {
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),
currency: this.mapsValue(source, "quote_currency", Currency.createFromCode),
language: this.mapsValue(source, "quote_language", Language.createFromCode),
customer: this.mapsValue(source, "customer", QuoteCustomer.create),
currency: this.mapsValue(source, "currency_code", CurrencyData.createFromCode),
language: this.mapsValue(source, "lang_code", Language.createFromCode),
customer: this.mapsValue(source, "customer_information", QuoteCustomer.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),
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),
};
@ -75,9 +98,9 @@ class QuoteMapper
payment_method: source.paymentMethod.toPrimitive(),
notes: source.notes.toPrimitive(),
discount: 0,
subtotal: 0,
total: 0,
subtotal_price: source.subtotalPrice.toPrimitive(),
discount: source.discount.toPrimitive(),
total_price: source.totalPrice.toPrimitive(),
items,
dealer_id: source.dealerId.toPrimitive(),

View File

@ -1,5 +1,5 @@
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 { ISalesContext } from "../Sales.context";
import { Quote_Model } from "../sequelize";
@ -23,15 +23,40 @@ class QuoteItemMapper
const id = this.mapsValue(source, "item_id", UniqueID.create);
const props: IQuoteItemProps = {
articleId: source.id_article,
description: this.mapsValue(source, "description", Description.create),
quantity: this.mapsValue(source, "quantity", Quantity.create),
unitPrice: this.mapsValue(source, "unit_price", (unit_price) =>
UnitPrice.create({
MoneyValue.create({
amount: unit_price,
currencyCode: sourceParent.currency_code,
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);
@ -50,16 +75,16 @@ class QuoteItemMapper
const { index, sourceParent } = params;
return {
item_id: source.id.toString(),
quote_id: sourceParent.id.toPrimitive(),
position: index,
item_id: "", //article_id: source.id.toPrimitive(),
id_article: source.articleId,
description: source.description.toPrimitive(),
quantity: source.quantity.toPrimitive(),
unit_price: source.unitPrice.toPrimitive(),
subtotal: 0,
total: 0,
//subtotal: source.calculateSubtotal().toPrimitive(),
//total: source.calculateTotal().toPrimitive(),
subtotal_price: source.subtotalPrice.toPrimitive(),
discount: source.discount.toPrimitive(),
total_price: source.totalPrice.toPrimitive(),
};
}
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import { checkUser } from "@/contexts/auth";
import {
createQuoteController,
getQuoteController,
listQuotesController,
updateQuoteController,
} from "@/contexts/sales/infrastructure/express/controllers";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/dealerMiddleware";
import Express from "express";
@ -10,11 +12,11 @@ export const QuoteRouter = (appRouter: Express.Router) => {
const quoteRoutes: Express.Router = Express.Router({ mergeParams: true });
quoteRoutes.get("/", checkUser, getDealerMiddleware, listQuotesController);
quoteRoutes.get("/:quoteId", checkUser, getDealerMiddleware, getQuoteController);
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.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 {
id: string;
@ -10,5 +10,5 @@ export interface IListArticles_Response_DTO {
description: string;
points: number;
retail_price: IMoney_Response_DTO;
retail_price: IQuantuty_Response_DTO;
}

View File

@ -1,5 +1,5 @@
import {
Currency,
CurrencyData,
Description,
Email,
Language,
@ -46,7 +46,7 @@ export const ensureDateIsValid = (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);
};

View File

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

View File

@ -1,6 +1,7 @@
export interface IPercentage_DTO {
amount: number;
precision: number;
amount: number;
precision: number;
}
export interface IPercentage_Request_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 "./IMoney.dto";
export * from "./IPercentage.dto";
export * from "./IQuantity.dto";
export * from "./ITaxType.dto";

View File

@ -1,6 +1,8 @@
export interface ICollection<T> {
items: T[];
totalCount: number;
toArray(): 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(
(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) {
this._items = new Map<number, T>(
initialValues
? initialValues.map(
(value: any, index: number) => [index, value] as [number, T],
)
: [],
? initialValues.map((value: any, index: number) => [index, value] as [number, T])
: []
);
this._totalCountIsProvided = typeof totalCount === "number";
@ -89,9 +89,7 @@ export class Collection<T> implements ICollection<T> {
}
}
public find(
predicate: (value: T, index: number, obj: T[]) => unknown,
): T | undefined {
public find(predicate: (value: T, index: number, obj: T[]) => unknown): T | undefined {
return Array.from(this._items.values()).find(predicate);
}
@ -106,8 +104,8 @@ export class Collection<T> implements ICollection<T> {
}
public toStringArray(): string[] {
return Array.from(this._items.values(), (element) =>
JSON.stringify(element),
).filter((element) => element.length > 0);
return Array.from(this._items.values(), (element) => JSON.stringify(element)).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 { Result } from "../Result";
export interface ICurrency {
export interface ICurrencyData {
symbol: string;
name: string;
symbol_native: string;
@ -15,9 +15,9 @@ export interface ICurrency {
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 CURRENCIES = Currencies;
@ -29,7 +29,7 @@ export class Currency extends NullableValueObject<ICurrency> {
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(
RuleValidator.RULE_ALLOW_EMPTY.default(""),
Joi.string()
@ -41,24 +41,24 @@ export class Currency extends NullableValueObject<ICurrency> {
return RuleValidator.validate<string>(rule, value);
}
public static createFromCode(currencyCode: string, options: ICurrencyOptions = {}) {
public static createFromCode(currencyCode: string, options: ICurrencyDataOptions = {}) {
const _options = {
...options,
label: options.label ? options.label : "current_code",
};
const validationResult = Currency.validate(currencyCode, _options);
const validationResult = CurrencyData.validate(currencyCode, _options);
if (validationResult.isFailure) {
return Result.fail(
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() {
return Currency.createFromCode(Currency.DEFAULT_CURRENCY_CODE);
return CurrencyData.createFromCode(CurrencyData.DEFAULT_CURRENCY_CODE);
}
public isEmpty(): boolean {

View File

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

View File

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

View File

@ -1,57 +1,160 @@
import Joi from "joi";
import { isNull } from "lodash";
import { NullOr } from "../../../../utilities";
import { RuleValidator } from "../RuleValidator";
import {
INullableValueObjectOptions,
NullableValueObject,
} from "./NullableValueObject";
import { INullableValueObjectOptions, NullableValueObject } from "./NullableValueObject";
import { Result } from "./Result";
export class Percentage extends NullableValueObject<number> {
private static readonly MIN_VALUE = 0;
private static readonly MAX_VALUE = 100;
export interface IPercentageOptions extends INullableValueObjectOptions {}
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()
.min(Percentage.MIN_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(
value: number,
options: INullableValueObjectOptions = {},
props: IPercentageProps = defaultPercentageProps,
options: IPercentageOptions = {}
) {
if (props === null) {
throw new Error(`InvalidParams: props params is missing`);
}
const { amount = defaultPercentageProps.amount, precision = defaultPercentageProps.precision } =
props;
const _options = {
label: "percentage",
...options,
};
const validationResult = Percentage.validate(value, _options);
const validationResult = Percentage.validate(amount, _options);
if (validationResult.isFailure) {
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 {
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 {
return this.isNull() ? "" : String(this.value);
return this.isNull() ? "" : String(this.toNumber());
}
public toPrimitive(): number {
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;
}
export interface QuantityObject {
amount: number;
precision: number;
}
const defaultQuantityProps = {
amount: 1,
amount: 0,
precision: 0,
};
export class Quantity extends NullableValueObject<IQuantity> {
public static readonly DEFAULT_PRECISION = defaultQuantityProps.precision;
private readonly _isNull: boolean;
private readonly _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(
options.label ? options.label : "quantity"
@ -100,6 +109,14 @@ export class Quantity extends NullableValueObject<IQuantity> {
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();
};
@ -130,9 +147,9 @@ export class Quantity extends NullableValueObject<IQuantity> {
return this.toObject();
}
public toObject(): IQuantityProps {
public toObject(): QuantityObject {
return {
amount: this.amount,
amount: this.amount ? this.amount : 0,
precision: this.precision,
};
}

View File

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

View File

@ -1,5 +1,11 @@
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 {
id: string;
@ -13,14 +19,23 @@ export interface ICreateQuote_Request_DTO {
notes: string;
validity: string;
subtotal: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total: IMoney_Response_DTO;
items: ICreateQuoteItem_Request_DTO[];
dealer_id: string;
}
export interface ICreateQuoteItem_Request_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Request_DTO;
unit_price: IMoney_Response_DTO;
price: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total_price: IMoney_Response_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(
Joi.object({
article_id: Joi.string(),
description: Joi.string(),
quantity: Joi.string(),
unit_measure: Joi.string(),
quantity: {
amount: Joi.number(),
precision: Joi.number(),
},
unit_price: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
currency: Joi.string(),
}),
discount: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
}).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 {
id: string;
status: string;
date: string;
reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
items: ICreateQuote_QuoteItem_Response_DTO[];
}
export interface ICreateQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
price: 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 {
id: string;
@ -8,21 +12,26 @@ export interface IGetQuote_Response_DTO {
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
subtotal_price: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
items: IGetQuote_QuoteItem_Response_DTO[];
dealer_id: string;
}
export interface IGetQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
subtotal_price: 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 {
id: string;
@ -9,6 +9,6 @@ export interface IListQuotes_Response_DTO {
lang_code: string;
currency_code: string;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
subtotal: IQuantuty_Response_DTO;
total: IQuantuty_Response_DTO;
}

View File

@ -1,5 +1,13 @@
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 {
status: string;
@ -12,37 +20,71 @@ export interface IUpdateQuote_Request_DTO {
notes: string;
validity: string;
subtotal: IMoney_Request_DTO;
discount: IPercentage_Request_DTO;
items: IUpdateQuoteItem_Request_DTO[];
}
export interface IUpdateQuoteItem_Request_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Request_DTO;
unit_price: IMoney_Response_DTO;
subtotal_price: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
}
export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Request_DTO) {
const schema = Joi.object({
status: Joi.string(),
date: Joi.string(),
reference: Joi.string(),
lang_code: Joi.string(),
customer_information: Joi.string(),
lang_code: Joi.string(),
currency_code: Joi.string(),
payment_method: Joi.string(),
notes: 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(
Joi.object({
article_id: Joi.string(),
quantity: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
}),
description: Joi.string(),
quantity: Joi.string(),
unit_measure: Joi.string(),
unit_price: Joi.object({
amount: Joi.number(),
precision: Joi.number(),
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);

View File

@ -1,23 +1,33 @@
import { IMoney_Response_DTO } from "shared/lib/contexts/common";
import { IPercentage_Response_DTO, IQuantity_Response_DTO } from "../../../../../common";
export interface IUpdateQuote_Response_DTO {
id: string;
status: string;
date: string;
language_code: string;
reference: string;
customer_information: string;
lang_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: IUpdateQuote_QuoteItem_Response_DTO[];
dealer_id: string;
}
export interface IUpdateQuote_QuoteItem_Response_DTO {
article_id: string;
quantity: IQuantity_Response_DTO;
description: string;
quantity: string;
unit_measure: string;
unit_price: IMoney_Response_DTO;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;
price: IMoney_Response_DTO;
discount: IPercentage_Response_DTO;
total_price: IMoney_Response_DTO;
}