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,
|
||||
RowData,
|
||||
RowSelectionState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
@ -75,6 +74,9 @@ export type QuoteItemsSortableDataTableProps<
|
||||
pickCatalogArticle?: () => void;
|
||||
pickBlock?: () => void;
|
||||
};
|
||||
showLineDiscountColumn: boolean;
|
||||
hasLineDiscounts: boolean;
|
||||
onShowLineDiscountColumnChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const measuringConfig = {
|
||||
@ -112,14 +114,13 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
||||
defaultValues,
|
||||
initialState,
|
||||
actions,
|
||||
showLineDiscountColumn,
|
||||
hasLineDiscounts,
|
||||
onShowLineDiscountColumnChange,
|
||||
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
||||
|
||||
const [columnVisibility, _setColumnVisibility] = useState<VisibilityState>(
|
||||
initialState?.columnVisibility || {}
|
||||
);
|
||||
|
||||
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
@ -133,7 +134,10 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
||||
initialState,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
columnVisibility: {
|
||||
...initialState?.columnVisibility,
|
||||
discount: showLineDiscountColumn,
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
@ -294,7 +298,12 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
|
||||
<Card>
|
||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
||||
<CardTitle>
|
||||
<QuoteItemsSortableDataTableToolbar table={table} />
|
||||
<QuoteItemsSortableDataTableToolbar
|
||||
table={table}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||
/>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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 { t } from "i18next";
|
||||
import { CopyPlusIcon, ScanIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
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 [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) {
|
||||
return (
|
||||
@ -62,6 +141,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table<any
|
||||
<p className='text-sm font-normal'>
|
||||
{t("common.rows_selected", { count: selectedRowsCount })}
|
||||
</p>
|
||||
{discountColumnControl}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
@ -103,7 +183,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table<any
|
||||
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 ml-auto'></div>
|
||||
{discountColumnControl}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@ -31,7 +31,13 @@ export const QuoteDetailsCardEditor = ({
|
||||
defaultValues: Readonly<{ [x: string]: any }> | undefined;
|
||||
}) => {
|
||||
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");
|
||||
|
||||
@ -286,6 +292,11 @@ export const QuoteDetailsCardEditor = ({
|
||||
columns={columns}
|
||||
data={fields}
|
||||
defaultValues={defaultValues}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={(value) =>
|
||||
setValue("show_line_discount_column", value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<CatalogPickerDialog
|
||||
onSelect={handleAppendCatalogArticle}
|
||||
@ -327,6 +338,11 @@ export const QuoteDetailsCardEditor = ({
|
||||
columns={columns}
|
||||
data={fields}
|
||||
defaultValues={defaultValues}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={(value) =>
|
||||
setValue("show_line_discount_column", value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className='mx-3' />
|
||||
|
||||
@ -62,6 +62,7 @@ export const QuoteEdit = () => {
|
||||
payment_method: "",
|
||||
notes: "",
|
||||
validity: "",
|
||||
show_line_discount_column: true,
|
||||
subtotal_price: {
|
||||
amount: undefined,
|
||||
scale: 2,
|
||||
|
||||
@ -275,6 +275,15 @@
|
||||
"submit_button": "Change status",
|
||||
"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": {
|
||||
"draft": "Draft",
|
||||
"ready": "Ready",
|
||||
|
||||
@ -284,6 +284,15 @@
|
||||
"submit_button": "Cambiar estado",
|
||||
"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": {
|
||||
"draft": "Borrador",
|
||||
"ready": "Preparado",
|
||||
|
||||
@ -283,6 +283,7 @@ export class CreateQuoteUseCase
|
||||
paymentMethod: paymentOrError.object,
|
||||
notes: notesOrError.object,
|
||||
validity: validityOrError.object,
|
||||
showLineDiscountColumn: quoteDTO.show_line_discount_column ?? true,
|
||||
|
||||
discount: discountOrError.object,
|
||||
tax: taxOrError.object,
|
||||
|
||||
@ -115,6 +115,7 @@ export class DuplicateQuoteUseCase
|
||||
paymentMethod: sourceQuote.paymentMethod,
|
||||
notes: sourceQuote.notes,
|
||||
validity: sourceQuote.validity,
|
||||
showLineDiscountColumn: sourceQuote.showLineDiscountColumn,
|
||||
|
||||
discount: sourceQuote.discount,
|
||||
tax: sourceQuote.tax,
|
||||
|
||||
@ -73,8 +73,8 @@ export class UpdateQuoteUseCase
|
||||
const dealerId = this._dealer.id;
|
||||
|
||||
// Comprobar que existe el Quote
|
||||
const idExists = await QuoteRepository().exists(id);
|
||||
if (!idExists) {
|
||||
const existingQuote = await QuoteRepository().getById(id);
|
||||
if (!existingQuote) {
|
||||
const message = `Quote ID not found`;
|
||||
return Result.fail(
|
||||
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
|
||||
@ -87,7 +87,12 @@ export class UpdateQuoteUseCase
|
||||
// regla de negocio -> no poder modificar un quote enviado
|
||||
|
||||
// Crear quote
|
||||
const quoteOrError = this._tryCreateQuoteInstance(quoteDTO, id, dealerId);
|
||||
const quoteOrError = this._tryCreateQuoteInstance(
|
||||
quoteDTO,
|
||||
id,
|
||||
dealerId,
|
||||
existingQuote.showLineDiscountColumn
|
||||
);
|
||||
|
||||
if (quoteOrError.isFailure) {
|
||||
const { error: domainError } = quoteOrError;
|
||||
@ -135,7 +140,8 @@ export class UpdateQuoteUseCase
|
||||
private _tryCreateQuoteInstance(
|
||||
quoteDTO: IUpdateQuote_Request_DTO,
|
||||
quoteId: UniqueID,
|
||||
dealerId: UniqueID
|
||||
dealerId: UniqueID,
|
||||
existingShowLineDiscountColumn: boolean
|
||||
): Result<Quote, IDomainError> {
|
||||
const statusOrError = QuoteStatus.create(quoteDTO.status);
|
||||
if (statusOrError.isFailure) {
|
||||
@ -275,6 +281,8 @@ export class UpdateQuoteUseCase
|
||||
paymentMethod: paymentOrError.object,
|
||||
notes: notesOrError.object,
|
||||
validity: validityOrError.object,
|
||||
showLineDiscountColumn:
|
||||
quoteDTO.show_line_discount_column ?? existingShowLineDiscountColumn,
|
||||
|
||||
discount: discountOrError.object,
|
||||
tax: taxOrError.object,
|
||||
|
||||
@ -27,6 +27,7 @@ export interface IQuoteProps {
|
||||
paymentMethod: Note;
|
||||
notes: Note;
|
||||
validity: Note;
|
||||
showLineDiscountColumn?: boolean;
|
||||
|
||||
items: ICollection<QuoteItem>;
|
||||
|
||||
@ -53,6 +54,7 @@ export interface IQuote {
|
||||
paymentMethod: Note;
|
||||
notes: Note;
|
||||
validity: Note;
|
||||
showLineDiscountColumn: boolean;
|
||||
|
||||
subtotalPrice: MoneyValue;
|
||||
|
||||
@ -148,6 +150,10 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
|
||||
return this.props.validity;
|
||||
}
|
||||
|
||||
get showLineDiscountColumn(): boolean {
|
||||
return this.props.showLineDiscountColumn ?? true;
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this._items;
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ export const CreateQuotePresenter: ICreateQuotePresenter = {
|
||||
payment_method: quote.paymentMethod.toString(),
|
||||
validity: quote.validity.toString(),
|
||||
notes: quote.notes.toString(),
|
||||
show_line_discount_column: quote.showLineDiscountColumn,
|
||||
|
||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ export const DuplicateQuotePresenter: IDuplicateQuotePresenter = {
|
||||
payment_method: quote.paymentMethod.toString(),
|
||||
validity: quote.validity.toString(),
|
||||
notes: quote.notes.toString(),
|
||||
show_line_discount_column: quote.showLineDiscountColumn,
|
||||
|
||||
subtotal_price: quote.subtotalPrice.convertScale(2).toObject(),
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ export const GetQuotePresenter: IGetQuotePresenter = {
|
||||
payment_method: quote.paymentMethod.toString(),
|
||||
validity: quote.validity.toString(),
|
||||
notes: quote.notes.toString(),
|
||||
show_line_discount_column: quote.showLineDiscountColumn,
|
||||
|
||||
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(),
|
||||
validity: quote.validity.toString(),
|
||||
notes: quote.notes.toString(),
|
||||
show_line_discount_column: quote.showLineDiscountColumn,
|
||||
has_general_discount: quote.discount.toNumber() > 0,
|
||||
|
||||
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 text-right border">Prec. Unitario</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>
|
||||
{{/if}}
|
||||
<th class="px-2 py-2 text-right border">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -88,7 +90,9 @@
|
||||
<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">{{subtotal_price}}</td>
|
||||
{{#if ../show_line_discount_column}}
|
||||
<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>
|
||||
</tr>
|
||||
{{/each}}
|
||||
@ -115,11 +119,13 @@
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
|
||||
</tr>
|
||||
{{#if has_general_discount}}
|
||||
<tr class="border-b">
|
||||
<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_price}}</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">Base imponible</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
@ -149,4 +155,4 @@
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -26,6 +26,7 @@ export const UpdateQuotePresenter: IUpdateQuotePresenter = {
|
||||
payment_method: quote.paymentMethod.toString(),
|
||||
validity: quote.validity.toString(),
|
||||
notes: quote.notes.toString(),
|
||||
show_line_discount_column: quote.showLineDiscountColumn,
|
||||
|
||||
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),
|
||||
paymentMethod: this.mapsValue(source, "payment_method", Note.create),
|
||||
notes: this.mapsValue(source, "notes", TextValueObject.create),
|
||||
showLineDiscountColumn: source.show_line_discount_column ?? true,
|
||||
|
||||
items,
|
||||
|
||||
@ -126,6 +127,7 @@ class QuoteMapper
|
||||
validity: source.validity.toPrimitive(),
|
||||
payment_method: source.paymentMethod.toPrimitive(),
|
||||
notes: source.notes.toPrimitive(),
|
||||
show_line_discount_column: source.showLineDiscountColumn,
|
||||
|
||||
subtotal_price: source.subtotalPrice.convertScale(2).toPrimitive(),
|
||||
|
||||
|
||||
@ -50,6 +50,7 @@ export class Quote_Model extends Model<
|
||||
declare payment_method: CreationOptional<string>;
|
||||
declare notes: CreationOptional<string>;
|
||||
declare validity: CreationOptional<string>;
|
||||
declare show_line_discount_column: CreationOptional<boolean>;
|
||||
|
||||
declare subtotal_price: CreationOptional<number | null>;
|
||||
|
||||
@ -124,6 +125,12 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
show_line_discount_column: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
subtotal_price: {
|
||||
type: new DataTypes.BIGINT(),
|
||||
allowNull: true,
|
||||
|
||||
@ -18,6 +18,7 @@ export interface ICreateQuote_Request_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column?: boolean;
|
||||
|
||||
subtotal_price: IMoney_Response_DTO;
|
||||
discount: IPercentage_Response_DTO;
|
||||
@ -47,6 +48,7 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
|
||||
reference: Joi.string(),
|
||||
customer_reference: Joi.string(),
|
||||
customer_information: Joi.string(),
|
||||
show_line_discount_column: Joi.boolean(),
|
||||
}).unknown(true);
|
||||
|
||||
const result = RuleValidator.validate<ICreateQuote_Request_DTO>(schema, quoteDTO);
|
||||
|
||||
@ -13,6 +13,7 @@ export interface ICreateQuote_Response_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column: boolean;
|
||||
|
||||
subtotal_price: IMoney_DTO;
|
||||
discount: IPercentage_DTO;
|
||||
|
||||
@ -13,6 +13,7 @@ export interface IDuplicateQuote_Response_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column: boolean;
|
||||
|
||||
subtotal_price: IMoney_DTO;
|
||||
discount: IPercentage_DTO;
|
||||
|
||||
@ -13,6 +13,7 @@ export interface IGetQuote_Response_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column: boolean;
|
||||
|
||||
subtotal_price: IMoney_DTO;
|
||||
discount: IPercentage_DTO;
|
||||
|
||||
@ -18,6 +18,7 @@ export interface IUpdateQuote_Request_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column?: boolean;
|
||||
|
||||
subtotal_price: IMoney_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(""),
|
||||
notes: 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({
|
||||
amount: Joi.number().allow(null),
|
||||
|
||||
@ -13,6 +13,7 @@ export interface IUpdateQuote_Response_DTO {
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
validity: string;
|
||||
show_line_discount_column: boolean;
|
||||
|
||||
subtotal_price: IMoney_DTO;
|
||||
discount: IPercentage_DTO;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user