diff --git a/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx b/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx
index eb3a62f..c67e5d7 100644
--- a/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx
+++ b/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx
@@ -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) {
const [rowSelection, setRowSelection] = useState({});
const [activeId, setActiveId] = useState();
- const [columnVisibility, _setColumnVisibility] = useState(
- initialState?.columnVisibility || {}
- );
-
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
const table = useReactTable({
@@ -133,7 +134,10 @@ export function QuoteItemsSortableDataTable
-
+
diff --git a/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.test.tsx b/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.test.tsx
new file mode 100644
index 0000000..29f5aae
--- /dev/null
+++ b/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.test.tsx
@@ -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) => ,
+ 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 }) => ,
+ Separator: () => null,
+ Switch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (value: boolean) => void }) => (
+ 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;
+
+ 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(
+
+ );
+ });
+
+ 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(
+
+ );
+ });
+
+ 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);
+ });
+});
diff --git a/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.tsx b/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.tsx
index 49c1dee..e7c17aa 100644
--- a/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.tsx
+++ b/client/src/app/quotes/components/QuoteItemsSortableDataTableToolbar.tsx
@@ -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 }) => {
+export const QuoteItemsSortableDataTableToolbar = ({
+ table,
+ showLineDiscountColumn,
+ hasLineDiscounts,
+ onShowLineDiscountColumnChange,
+}: {
+ table: Table;
+ 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 = (
+ <>
+
+
+
+
+ {t("quotes.discount_column.description")}
+
+
+
+
+
+ >
+ );
if (selectedRowsCount) {
return (
@@ -62,6 +141,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table
{t("common.rows_selected", { count: selectedRowsCount })}
+ {discountColumnControl}
);
@@ -103,7 +183,7 @@ export const QuoteItemsSortableDataTableToolbar = ({ table }: { table: Table{t("common.append_block_tooltip")}
-
+ {discountColumnControl}
);
};
diff --git a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx
index 9d763d4..fe52b14 100644
--- a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx
+++ b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx
@@ -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 })
+ }
/>
+ setValue("show_line_discount_column", value, { shouldDirty: true })
+ }
/>
diff --git a/client/src/app/quotes/edit.tsx b/client/src/app/quotes/edit.tsx
index 5ed22e1..19dfdcd 100644
--- a/client/src/app/quotes/edit.tsx
+++ b/client/src/app/quotes/edit.tsx
@@ -62,6 +62,7 @@ export const QuoteEdit = () => {
payment_method: "",
notes: "",
validity: "",
+ show_line_discount_column: true,
subtotal_price: {
amount: undefined,
scale: 2,
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 8aa988f..25babd6 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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",
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index 66f41ed..d14ab18 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -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",
diff --git a/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts b/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts
index b301a3b..3a0a578 100644
--- a/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts
+++ b/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts
@@ -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,
diff --git a/server/src/contexts/sales/application/Quote/DuplicateQuote.useCase.ts b/server/src/contexts/sales/application/Quote/DuplicateQuote.useCase.ts
index b11ff4b..118fbed 100644
--- a/server/src/contexts/sales/application/Quote/DuplicateQuote.useCase.ts
+++ b/server/src/contexts/sales/application/Quote/DuplicateQuote.useCase.ts
@@ -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,
diff --git a/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts b/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts
index 62e9603..9da32bb 100644
--- a/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts
+++ b/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts
@@ -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 {
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,
diff --git a/server/src/contexts/sales/domain/entities/Quotes/Quote.ts b/server/src/contexts/sales/domain/entities/Quotes/Quote.ts
index 2c7a163..827f55b 100644
--- a/server/src/contexts/sales/domain/entities/Quotes/Quote.ts
+++ b/server/src/contexts/sales/domain/entities/Quotes/Quote.ts
@@ -27,6 +27,7 @@ export interface IQuoteProps {
paymentMethod: Note;
notes: Note;
validity: Note;
+ showLineDiscountColumn?: boolean;
items: ICollection;
@@ -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 implements IQuote {
return this.props.validity;
}
+ get showLineDiscountColumn(): boolean {
+ return this.props.showLineDiscountColumn ?? true;
+ }
+
get items() {
return this._items;
}
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/presenter/CreateQuote.presenter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/presenter/CreateQuote.presenter.ts
index 3f3b5aa..40bb095 100644
--- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/presenter/CreateQuote.presenter.ts
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/presenter/CreateQuote.presenter.ts
@@ -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(),
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/duplicateQuote/presenter/DuplicateQuote.presenter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/duplicateQuote/presenter/DuplicateQuote.presenter.ts
index 8a80a82..dd7d6df 100644
--- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/duplicateQuote/presenter/DuplicateQuote.presenter.ts
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/duplicateQuote/presenter/DuplicateQuote.presenter.ts
@@ -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(),
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts
index ca541af..17690a0 100644
--- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts
@@ -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(),
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts
new file mode 100644
index 0000000..cf944de
--- /dev/null
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts
@@ -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");
+ });
+});
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts
index 43e0f91..04ad929 100644
--- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts
@@ -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(),
diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/templates/quote/template.hbs b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/templates/quote/template.hbs
index a14b7fe..0374d69 100644
--- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/templates/quote/template.hbs
+++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/templates/quote/template.hbs
@@ -77,7 +77,9 @@
| Descripción |
Prec. Unitario |
Subtotal |
+ {{#if show_line_discount_column}}
Dto (%) |
+ {{/if}}
Importe total |
@@ -88,7 +90,9 @@
{{description}} |
{{unit_price}} |
{{subtotal_price}} |
+ {{#if ../show_line_discount_column}}
{{discount}} |
+ {{/if}}
{{total_price}} |
{{/each}}
@@ -115,11 +119,13 @@
|
{{subtotal_price}} |
+ {{#if has_general_discount}}
| % Descuento |
{{discount.amount}} |
{{discount_price}} |
+ {{/if}}
| Base imponible |
|
@@ -149,4 +155,4 @@