Poder mostrar u ocultar la columna de descuento en los detalles de una cotización
This commit is contained in:
parent
b0bfd172e6
commit
c46d2b0df7
@ -26,7 +26,6 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
RowData,
|
RowData,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
VisibilityState,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
@ -75,6 +74,9 @@ export type QuoteItemsSortableDataTableProps<
|
|||||||
pickCatalogArticle?: () => void;
|
pickCatalogArticle?: () => void;
|
||||||
pickBlock?: () => void;
|
pickBlock?: () => void;
|
||||||
};
|
};
|
||||||
|
showLineDiscountColumn: boolean;
|
||||||
|
hasLineDiscounts: boolean;
|
||||||
|
onShowLineDiscountColumnChange: (value: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const measuringConfig = {
|
const measuringConfig = {
|
||||||
@ -112,14 +114,13 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
initialState,
|
initialState,
|
||||||
actions,
|
actions,
|
||||||
|
showLineDiscountColumn,
|
||||||
|
hasLineDiscounts,
|
||||||
|
onShowLineDiscountColumnChange,
|
||||||
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
|
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
||||||
|
|
||||||
const [columnVisibility, _setColumnVisibility] = useState<VisibilityState>(
|
|
||||||
initialState?.columnVisibility || {}
|
|
||||||
);
|
|
||||||
|
|
||||||
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
|
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
|
||||||
|
|
||||||
const table = useReactTable<TData>({
|
const table = useReactTable<TData>({
|
||||||
@ -133,7 +134,10 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
|||||||
initialState,
|
initialState,
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
rowSelection,
|
||||||
columnVisibility,
|
columnVisibility: {
|
||||||
|
...initialState?.columnVisibility,
|
||||||
|
discount: showLineDiscountColumn,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
enableMultiRowSelection: true,
|
enableMultiRowSelection: true,
|
||||||
@ -294,7 +298,12 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
<QuoteItemsSortableDataTableToolbar table={table} />
|
<QuoteItemsSortableDataTableToolbar
|
||||||
|
table={table}
|
||||||
|
showLineDiscountColumn={showLineDiscountColumn}
|
||||||
|
hasLineDiscounts={hasLineDiscounts}
|
||||||
|
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||||
|
/>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
jest.mock("i18next", () => ({ t: (key: string) => key }));
|
||||||
|
jest.mock("./AppendBlockRowButton", () => ({ AppendBlockRowButton: () => null }));
|
||||||
|
jest.mock("./AppendCatalogArticleRowButton", () => ({ AppendCatalogArticleRowButton: () => null }));
|
||||||
|
jest.mock("./AppendEmptyRowButton", () => ({ AppendEmptyRowButton: () => null }));
|
||||||
|
jest.mock("@/ui", () => {
|
||||||
|
return {
|
||||||
|
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />,
|
||||||
|
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogFooter: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
Label: ({ children }: { children: ReactNode }) => <label>{children}</label>,
|
||||||
|
Separator: () => null,
|
||||||
|
Switch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (value: boolean) => void }) => (
|
||||||
|
<input
|
||||||
|
aria-label='discount-column-switch'
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||||
|
type='checkbox'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = {
|
||||||
|
getSelectedRowModel: () => ({ rows: [] }),
|
||||||
|
options: { meta: {} },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
describe("QuoteItemsSortableDataTableToolbar", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides directly when no line has a discount", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QuoteItemsSortableDataTableToolbar
|
||||||
|
table={table}
|
||||||
|
showLineDiscountColumn={true}
|
||||||
|
hasLineDiscounts={false}
|
||||||
|
onShowLineDiscountColumnChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(container.querySelector("input") as HTMLInputElement).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirmation and preserves the choice until confirmed when discounts exist", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QuoteItemsSortableDataTableToolbar
|
||||||
|
table={table}
|
||||||
|
showLineDiscountColumn={true}
|
||||||
|
hasLineDiscounts={true}
|
||||||
|
onShowLineDiscountColumnChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(container.querySelector("input") as HTMLInputElement).click();
|
||||||
|
});
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent === "quotes.discount_column.confirmation.confirm")
|
||||||
|
?.click();
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,13 +1,92 @@
|
|||||||
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Label,
|
||||||
|
Separator,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/ui";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { CopyPlusIcon, ScanIcon, Trash2Icon } from "lucide-react";
|
import { CopyPlusIcon, ScanIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||||
|
|
||||||
export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table<any> }) => {
|
export const QuoteItemsSortableDataTableToolbar = ({
|
||||||
|
table,
|
||||||
|
showLineDiscountColumn,
|
||||||
|
hasLineDiscounts,
|
||||||
|
onShowLineDiscountColumnChange,
|
||||||
|
}: {
|
||||||
|
table: Table<any>;
|
||||||
|
showLineDiscountColumn: boolean;
|
||||||
|
hasLineDiscounts: boolean;
|
||||||
|
onShowLineDiscountColumnChange: (value: boolean) => void;
|
||||||
|
}) => {
|
||||||
const selectedRowsCount = table.getSelectedRowModel().rows.length;
|
const selectedRowsCount = table.getSelectedRowModel().rows.length;
|
||||||
|
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCheckedChange = (checked: boolean) => {
|
||||||
|
if (!checked && showLineDiscountColumn && hasLineDiscounts) {
|
||||||
|
setConfirmationOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onShowLineDiscountColumnChange(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const discountColumnControl = (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center gap-2 ml-auto'>
|
||||||
|
<div className='grid gap-0.5'>
|
||||||
|
<Label htmlFor='show-line-discount-column' className='text-sm font-medium'>
|
||||||
|
{t("quotes.discount_column.label")}
|
||||||
|
</Label>
|
||||||
|
<p className='text-xs font-normal text-muted-foreground'>
|
||||||
|
{t("quotes.discount_column.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id='show-line-discount-column'
|
||||||
|
checked={showLineDiscountColumn}
|
||||||
|
onCheckedChange={handleCheckedChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Dialog open={confirmationOpen} onOpenChange={setConfirmationOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("quotes.discount_column.confirmation.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("quotes.discount_column.confirmation.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type='button' variant='secondary' onClick={() => setConfirmationOpen(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
onShowLineDiscountColumnChange(false);
|
||||||
|
setConfirmationOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("quotes.discount_column.confirmation.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedRowsCount) {
|
if (selectedRowsCount) {
|
||||||
return (
|
return (
|
||||||
@ -62,6 +141,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table<any
|
|||||||
<p className='text-sm font-normal'>
|
<p className='text-sm font-normal'>
|
||||||
{t("common.rows_selected", { count: selectedRowsCount })}
|
{t("common.rows_selected", { count: selectedRowsCount })}
|
||||||
</p>
|
</p>
|
||||||
|
{discountColumnControl}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@ -103,7 +183,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table<any
|
|||||||
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
|
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2 ml-auto'></div>
|
{discountColumnControl}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,7 +31,13 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
defaultValues: Readonly<{ [x: string]: any }> | undefined;
|
defaultValues: Readonly<{ [x: string]: any }> | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { control } = useFormContext();
|
const { control, getValues, setValue, watch } = useFormContext();
|
||||||
|
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
|
||||||
|
const hasLineDiscounts = Boolean(
|
||||||
|
getValues("items")?.some((item: { discount?: { amount?: number | null } }) =>
|
||||||
|
Number(item.discount?.amount) > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const [pickerMode] = useState<"dialog" | "panel">("dialog");
|
const [pickerMode] = useState<"dialog" | "panel">("dialog");
|
||||||
|
|
||||||
@ -286,6 +292,11 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={fields}
|
data={fields}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
|
showLineDiscountColumn={showLineDiscountColumn}
|
||||||
|
hasLineDiscounts={hasLineDiscounts}
|
||||||
|
onShowLineDiscountColumnChange={(value) =>
|
||||||
|
setValue("show_line_discount_column", value, { shouldDirty: true })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<CatalogPickerDialog
|
<CatalogPickerDialog
|
||||||
onSelect={handleAppendCatalogArticle}
|
onSelect={handleAppendCatalogArticle}
|
||||||
@ -327,6 +338,11 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={fields}
|
data={fields}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
|
showLineDiscountColumn={showLineDiscountColumn}
|
||||||
|
hasLineDiscounts={hasLineDiscounts}
|
||||||
|
onShowLineDiscountColumnChange={(value) =>
|
||||||
|
setValue("show_line_discount_column", value, { shouldDirty: true })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle className='mx-3' />
|
<ResizableHandle withHandle className='mx-3' />
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export const QuoteEdit = () => {
|
|||||||
payment_method: "",
|
payment_method: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
validity: "",
|
validity: "",
|
||||||
|
show_line_discount_column: true,
|
||||||
subtotal_price: {
|
subtotal_price: {
|
||||||
amount: undefined,
|
amount: undefined,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
|
|||||||
@ -275,6 +275,15 @@
|
|||||||
"submit_button": "Change status",
|
"submit_button": "Change status",
|
||||||
"toast_status_changed": "Quote status changed to: {{newStatus}}"
|
"toast_status_changed": "Quote status changed to: {{newStatus}}"
|
||||||
},
|
},
|
||||||
|
"discount_column": {
|
||||||
|
"label": "Show discount column",
|
||||||
|
"description": "Shows or hides the discount column in quote lines.",
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Hide line discounts",
|
||||||
|
"description": "There are lines with discounts applied. Hiding the column will not remove those discounts: they will still affect the quote amount, but will no longer be shown in the lines or PDF.",
|
||||||
|
"confirm": "Hide column"
|
||||||
|
}
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
"ready": "Ready",
|
"ready": "Ready",
|
||||||
|
|||||||
@ -284,6 +284,15 @@
|
|||||||
"submit_button": "Cambiar estado",
|
"submit_button": "Cambiar estado",
|
||||||
"toast_status_changed": "Estado de la cotización cambiado a: {{newStatus}}"
|
"toast_status_changed": "Estado de la cotización cambiado a: {{newStatus}}"
|
||||||
},
|
},
|
||||||
|
"discount_column": {
|
||||||
|
"label": "Mostrar columna descuento",
|
||||||
|
"description": "Muestra u oculta la columna de descuento en las líneas.",
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Ocultar descuentos de línea",
|
||||||
|
"description": "Hay líneas con descuento aplicado. Ocultar la columna no eliminará esos descuentos: seguirán afectando al importe de la cotización, pero dejarán de mostrarse en las líneas y en el PDF.",
|
||||||
|
"confirm": "Ocultar columna"
|
||||||
|
}
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "Borrador",
|
"draft": "Borrador",
|
||||||
"ready": "Preparado",
|
"ready": "Preparado",
|
||||||
|
|||||||
@ -283,6 +283,7 @@ export class CreateQuoteUseCase
|
|||||||
paymentMethod: paymentOrError.object,
|
paymentMethod: paymentOrError.object,
|
||||||
notes: notesOrError.object,
|
notes: notesOrError.object,
|
||||||
validity: validityOrError.object,
|
validity: validityOrError.object,
|
||||||
|
showLineDiscountColumn: quoteDTO.show_line_discount_column ?? true,
|
||||||
|
|
||||||
discount: discountOrError.object,
|
discount: discountOrError.object,
|
||||||
tax: taxOrError.object,
|
tax: taxOrError.object,
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export class DuplicateQuoteUseCase
|
|||||||
paymentMethod: sourceQuote.paymentMethod,
|
paymentMethod: sourceQuote.paymentMethod,
|
||||||
notes: sourceQuote.notes,
|
notes: sourceQuote.notes,
|
||||||
validity: sourceQuote.validity,
|
validity: sourceQuote.validity,
|
||||||
|
showLineDiscountColumn: sourceQuote.showLineDiscountColumn,
|
||||||
|
|
||||||
discount: sourceQuote.discount,
|
discount: sourceQuote.discount,
|
||||||
tax: sourceQuote.tax,
|
tax: sourceQuote.tax,
|
||||||
|
|||||||
@ -73,8 +73,8 @@ export class UpdateQuoteUseCase
|
|||||||
const dealerId = this._dealer.id;
|
const dealerId = this._dealer.id;
|
||||||
|
|
||||||
// Comprobar que existe el Quote
|
// Comprobar que existe el Quote
|
||||||
const idExists = await QuoteRepository().exists(id);
|
const existingQuote = await QuoteRepository().getById(id);
|
||||||
if (!idExists) {
|
if (!existingQuote) {
|
||||||
const message = `Quote ID not found`;
|
const message = `Quote ID not found`;
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
|
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
|
||||||
@ -87,7 +87,12 @@ export class UpdateQuoteUseCase
|
|||||||
// regla de negocio -> no poder modificar un quote enviado
|
// regla de negocio -> no poder modificar un quote enviado
|
||||||
|
|
||||||
// Crear quote
|
// Crear quote
|
||||||
const quoteOrError = this._tryCreateQuoteInstance(quoteDTO, id, dealerId);
|
const quoteOrError = this._tryCreateQuoteInstance(
|
||||||
|
quoteDTO,
|
||||||
|
id,
|
||||||
|
dealerId,
|
||||||
|
existingQuote.showLineDiscountColumn
|
||||||
|
);
|
||||||
|
|
||||||
if (quoteOrError.isFailure) {
|
if (quoteOrError.isFailure) {
|
||||||
const { error: domainError } = quoteOrError;
|
const { error: domainError } = quoteOrError;
|
||||||
@ -135,7 +140,8 @@ export class UpdateQuoteUseCase
|
|||||||
private _tryCreateQuoteInstance(
|
private _tryCreateQuoteInstance(
|
||||||
quoteDTO: IUpdateQuote_Request_DTO,
|
quoteDTO: IUpdateQuote_Request_DTO,
|
||||||
quoteId: UniqueID,
|
quoteId: UniqueID,
|
||||||
dealerId: UniqueID
|
dealerId: UniqueID,
|
||||||
|
existingShowLineDiscountColumn: boolean
|
||||||
): Result<Quote, IDomainError> {
|
): Result<Quote, IDomainError> {
|
||||||
const statusOrError = QuoteStatus.create(quoteDTO.status);
|
const statusOrError = QuoteStatus.create(quoteDTO.status);
|
||||||
if (statusOrError.isFailure) {
|
if (statusOrError.isFailure) {
|
||||||
@ -275,6 +281,8 @@ export class UpdateQuoteUseCase
|
|||||||
paymentMethod: paymentOrError.object,
|
paymentMethod: paymentOrError.object,
|
||||||
notes: notesOrError.object,
|
notes: notesOrError.object,
|
||||||
validity: validityOrError.object,
|
validity: validityOrError.object,
|
||||||
|
showLineDiscountColumn:
|
||||||
|
quoteDTO.show_line_discount_column ?? existingShowLineDiscountColumn,
|
||||||
|
|
||||||
discount: discountOrError.object,
|
discount: discountOrError.object,
|
||||||
tax: taxOrError.object,
|
tax: taxOrError.object,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export interface IQuoteProps {
|
|||||||
paymentMethod: Note;
|
paymentMethod: Note;
|
||||||
notes: Note;
|
notes: Note;
|
||||||
validity: Note;
|
validity: Note;
|
||||||
|
showLineDiscountColumn?: boolean;
|
||||||
|
|
||||||
items: ICollection<QuoteItem>;
|
items: ICollection<QuoteItem>;
|
||||||
|
|
||||||
@ -53,6 +54,7 @@ export interface IQuote {
|
|||||||
paymentMethod: Note;
|
paymentMethod: Note;
|
||||||
notes: Note;
|
notes: Note;
|
||||||
validity: Note;
|
validity: Note;
|
||||||
|
showLineDiscountColumn: boolean;
|
||||||
|
|
||||||
subtotalPrice: MoneyValue;
|
subtotalPrice: MoneyValue;
|
||||||
|
|
||||||
@ -148,6 +150,10 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
|
|||||||
return this.props.validity;
|
return this.props.validity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showLineDiscountColumn(): boolean {
|
||||||
|
return this.props.showLineDiscountColumn ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
get items() {
|
get items() {
|
||||||
return this._items;
|
return this._items;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const CreateQuotePresenter: ICreateQuotePresenter = {
|
|||||||
payment_method: quote.paymentMethod.toString(),
|
payment_method: quote.paymentMethod.toString(),
|
||||||
validity: quote.validity.toString(),
|
validity: quote.validity.toString(),
|
||||||
notes: quote.notes.toString(),
|
notes: quote.notes.toString(),
|
||||||
|
show_line_discount_column: quote.showLineDiscountColumn,
|
||||||
|
|
||||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const DuplicateQuotePresenter: IDuplicateQuotePresenter = {
|
|||||||
payment_method: quote.paymentMethod.toString(),
|
payment_method: quote.paymentMethod.toString(),
|
||||||
validity: quote.validity.toString(),
|
validity: quote.validity.toString(),
|
||||||
notes: quote.notes.toString(),
|
notes: quote.notes.toString(),
|
||||||
|
show_line_discount_column: quote.showLineDiscountColumn,
|
||||||
|
|
||||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const GetQuotePresenter: IGetQuotePresenter = {
|
|||||||
payment_method: quote.paymentMethod.toString(),
|
payment_method: quote.paymentMethod.toString(),
|
||||||
validity: quote.validity.toString(),
|
validity: quote.validity.toString(),
|
||||||
notes: quote.notes.toString(),
|
notes: quote.notes.toString(),
|
||||||
|
show_line_discount_column: quote.showLineDiscountColumn,
|
||||||
|
|
||||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
|
||||||
|
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
|
||||||
|
import { ReportQuotePresenter } from "./ReportQuote.reporter";
|
||||||
|
|
||||||
|
const source = (showLineDiscountColumn: boolean, generalDiscount: number) =>
|
||||||
|
({
|
||||||
|
show_line_discount_column: showLineDiscountColumn,
|
||||||
|
items: [],
|
||||||
|
dataValues: {
|
||||||
|
id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
|
||||||
|
status: "draft",
|
||||||
|
date: "2026-10-03",
|
||||||
|
reference: "Q-1",
|
||||||
|
currency_code: "EUR",
|
||||||
|
lang_code: "es",
|
||||||
|
customer_reference: "",
|
||||||
|
customer_information: "Customer",
|
||||||
|
validity: "",
|
||||||
|
payment_method: "",
|
||||||
|
notes: "",
|
||||||
|
discount: generalDiscount,
|
||||||
|
tax: 21,
|
||||||
|
dealer_id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
|
||||||
|
date_sent: null,
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
describe("ReportQuotePresenter", () => {
|
||||||
|
const context = { dealer: undefined } as ISalesContext;
|
||||||
|
const mapper = createQuoteMapper(context);
|
||||||
|
|
||||||
|
it("renders the DTO column and general discount only when their flags are enabled", async () => {
|
||||||
|
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source(true, 10)), context);
|
||||||
|
|
||||||
|
expect(html).toContain("Dto (%)");
|
||||||
|
expect(html).toContain("% Descuento");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the DTO column and zero general discount", async () => {
|
||||||
|
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source(false, 0)), context);
|
||||||
|
|
||||||
|
expect(html).not.toContain("Dto (%)");
|
||||||
|
expect(html).not.toContain("% Descuento");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -77,6 +77,8 @@ const map = async (quote: Quote, context: ISalesContext) => {
|
|||||||
payment_method: quote.paymentMethod.toString(),
|
payment_method: quote.paymentMethod.toString(),
|
||||||
validity: quote.validity.toString(),
|
validity: quote.validity.toString(),
|
||||||
notes: quote.notes.toString(),
|
notes: quote.notes.toString(),
|
||||||
|
show_line_discount_column: quote.showLineDiscountColumn,
|
||||||
|
has_general_discount: quote.discount.toNumber() > 0,
|
||||||
|
|
||||||
subtotal_price: quote.subtotalPrice.toFormat(),
|
subtotal_price: quote.subtotalPrice.toFormat(),
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,9 @@
|
|||||||
<th class="px-2 py-2 border">Descripción</th>
|
<th class="px-2 py-2 border">Descripción</th>
|
||||||
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
|
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
|
||||||
<th class="px-2 py-2 text-right border">Subtotal</th>
|
<th class="px-2 py-2 text-right border">Subtotal</th>
|
||||||
|
{{#if show_line_discount_column}}
|
||||||
<th class="px-2 py-2 text-right border">Dto (%)</th>
|
<th class="px-2 py-2 text-right border">Dto (%)</th>
|
||||||
|
{{/if}}
|
||||||
<th class="px-2 py-2 text-right border">Importe total</th>
|
<th class="px-2 py-2 text-right border">Importe total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -88,7 +90,9 @@
|
|||||||
<td class="px-2 py-2 font-medium">{{description}}</td>
|
<td class="px-2 py-2 font-medium">{{description}}</td>
|
||||||
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
|
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
|
||||||
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
|
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
|
||||||
|
{{#if ../show_line_discount_column}}
|
||||||
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
|
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
|
||||||
|
{{/if}}
|
||||||
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
|
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
@ -115,11 +119,13 @@
|
|||||||
<td class="px-4 py-2"></td>
|
<td class="px-4 py-2"></td>
|
||||||
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
|
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{#if has_general_discount}}
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
<td class="px-4 py-2">% Descuento</td>
|
<td class="px-4 py-2">% Descuento</td>
|
||||||
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
|
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
|
||||||
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
|
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{/if}}
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
<td class="px-4 py-2">Base imponible</td>
|
<td class="px-4 py-2">Base imponible</td>
|
||||||
<td class="px-4 py-2"></td>
|
<td class="px-4 py-2"></td>
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const UpdateQuotePresenter: IUpdateQuotePresenter = {
|
|||||||
payment_method: quote.paymentMethod.toString(),
|
payment_method: quote.paymentMethod.toString(),
|
||||||
validity: quote.validity.toString(),
|
validity: quote.validity.toString(),
|
||||||
notes: quote.notes.toString(),
|
notes: quote.notes.toString(),
|
||||||
|
show_line_discount_column: quote.showLineDiscountColumn,
|
||||||
|
|
||||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { ISalesContext } from "../Sales.context";
|
||||||
|
import { createQuoteMapper } from "./quote.mapper";
|
||||||
|
|
||||||
|
const source = (showLineDiscountColumn: boolean | null) =>
|
||||||
|
({
|
||||||
|
show_line_discount_column: showLineDiscountColumn,
|
||||||
|
items: [],
|
||||||
|
dataValues: {
|
||||||
|
id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
|
||||||
|
status: "draft",
|
||||||
|
date: "2026-10-03",
|
||||||
|
reference: "Q-1",
|
||||||
|
currency_code: "EUR",
|
||||||
|
lang_code: "es",
|
||||||
|
customer_reference: "",
|
||||||
|
customer_information: "Customer",
|
||||||
|
validity: "",
|
||||||
|
payment_method: "",
|
||||||
|
notes: "",
|
||||||
|
discount: 0,
|
||||||
|
tax: 21,
|
||||||
|
dealer_id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
|
||||||
|
date_sent: null,
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
describe("QuoteMapper show_line_discount_column", () => {
|
||||||
|
const mapper = createQuoteMapper({} as ISalesContext);
|
||||||
|
|
||||||
|
it("maps false in both persistence directions", () => {
|
||||||
|
const quote = mapper.mapToDomain(source(false));
|
||||||
|
|
||||||
|
expect(quote.showLineDiscountColumn).toBe(false);
|
||||||
|
expect(mapper.mapToPersistence(quote).show_line_discount_column).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses true for historical null values", () => {
|
||||||
|
expect(mapper.mapToDomain(source(null)).showLineDiscountColumn).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -61,6 +61,7 @@ class QuoteMapper
|
|||||||
validity: this.mapsValue(source, "validity", Note.create),
|
validity: this.mapsValue(source, "validity", Note.create),
|
||||||
paymentMethod: this.mapsValue(source, "payment_method", Note.create),
|
paymentMethod: this.mapsValue(source, "payment_method", Note.create),
|
||||||
notes: this.mapsValue(source, "notes", TextValueObject.create),
|
notes: this.mapsValue(source, "notes", TextValueObject.create),
|
||||||
|
showLineDiscountColumn: source.show_line_discount_column ?? true,
|
||||||
|
|
||||||
items,
|
items,
|
||||||
|
|
||||||
@ -126,6 +127,7 @@ class QuoteMapper
|
|||||||
validity: source.validity.toPrimitive(),
|
validity: source.validity.toPrimitive(),
|
||||||
payment_method: source.paymentMethod.toPrimitive(),
|
payment_method: source.paymentMethod.toPrimitive(),
|
||||||
notes: source.notes.toPrimitive(),
|
notes: source.notes.toPrimitive(),
|
||||||
|
show_line_discount_column: source.showLineDiscountColumn,
|
||||||
|
|
||||||
subtotal_price: source.subtotalPrice.convertScale(2).toPrimitive(),
|
subtotal_price: source.subtotalPrice.convertScale(2).toPrimitive(),
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export class Quote_Model extends Model<
|
|||||||
declare payment_method: CreationOptional<string>;
|
declare payment_method: CreationOptional<string>;
|
||||||
declare notes: CreationOptional<string>;
|
declare notes: CreationOptional<string>;
|
||||||
declare validity: CreationOptional<string>;
|
declare validity: CreationOptional<string>;
|
||||||
|
declare show_line_discount_column: CreationOptional<boolean>;
|
||||||
|
|
||||||
declare subtotal_price: CreationOptional<number | null>;
|
declare subtotal_price: CreationOptional<number | null>;
|
||||||
|
|
||||||
@ -124,6 +125,12 @@ export default (sequelize: Sequelize) => {
|
|||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
show_line_discount_column: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
|
||||||
subtotal_price: {
|
subtotal_price: {
|
||||||
type: new DataTypes.BIGINT(),
|
type: new DataTypes.BIGINT(),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface ICreateQuote_Request_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column?: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_Response_DTO;
|
subtotal_price: IMoney_Response_DTO;
|
||||||
discount: IPercentage_Response_DTO;
|
discount: IPercentage_Response_DTO;
|
||||||
@ -47,6 +48,7 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
|
|||||||
reference: Joi.string(),
|
reference: Joi.string(),
|
||||||
customer_reference: Joi.string(),
|
customer_reference: Joi.string(),
|
||||||
customer_information: Joi.string(),
|
customer_information: Joi.string(),
|
||||||
|
show_line_discount_column: Joi.boolean(),
|
||||||
}).unknown(true);
|
}).unknown(true);
|
||||||
|
|
||||||
const result = RuleValidator.validate<ICreateQuote_Request_DTO>(schema, quoteDTO);
|
const result = RuleValidator.validate<ICreateQuote_Request_DTO>(schema, quoteDTO);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface ICreateQuote_Response_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_DTO;
|
subtotal_price: IMoney_DTO;
|
||||||
discount: IPercentage_DTO;
|
discount: IPercentage_DTO;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface IDuplicateQuote_Response_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_DTO;
|
subtotal_price: IMoney_DTO;
|
||||||
discount: IPercentage_DTO;
|
discount: IPercentage_DTO;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface IGetQuote_Response_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_DTO;
|
subtotal_price: IMoney_DTO;
|
||||||
discount: IPercentage_DTO;
|
discount: IPercentage_DTO;
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface IUpdateQuote_Request_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column?: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_Response_DTO;
|
subtotal_price: IMoney_Response_DTO;
|
||||||
discount: IPercentage_Response_DTO;
|
discount: IPercentage_Response_DTO;
|
||||||
@ -51,6 +52,7 @@ export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Requ
|
|||||||
payment_method: Joi.string().optional().allow(null).allow("").default(""),
|
payment_method: Joi.string().optional().allow(null).allow("").default(""),
|
||||||
notes: Joi.string().optional().allow(null).allow("").default(""),
|
notes: Joi.string().optional().allow(null).allow("").default(""),
|
||||||
validity: Joi.string().optional().allow(null).allow("").default(""),
|
validity: Joi.string().optional().allow(null).allow("").default(""),
|
||||||
|
show_line_discount_column: Joi.boolean(),
|
||||||
|
|
||||||
subtotal_price: Joi.object({
|
subtotal_price: Joi.object({
|
||||||
amount: Joi.number().allow(null),
|
amount: Joi.number().allow(null),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface IUpdateQuote_Response_DTO {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
show_line_discount_column: boolean;
|
||||||
|
|
||||||
subtotal_price: IMoney_DTO;
|
subtotal_price: IMoney_DTO;
|
||||||
discount: IPercentage_DTO;
|
discount: IPercentage_DTO;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user