Poder mostrar u ocultar la columna de descuento en los detalles de una cotización

This commit is contained in:
David Arranz 2026-10-03 13:57:15 +02:00
parent b0bfd172e6
commit c46d2b0df7
27 changed files with 378 additions and 16 deletions

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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>
);
};

View File

@ -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' />

View File

@ -62,6 +62,7 @@ export const QuoteEdit = () => {
payment_method: "",
notes: "",
validity: "",
show_line_discount_column: true,
subtotal_price: {
amount: undefined,
scale: 2,

View File

@ -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",

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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(),

View File

@ -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(),

View File

@ -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(),

View File

@ -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");
});
});

View File

@ -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(),

View File

@ -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>

View File

@ -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(),

View File

@ -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);
});
});

View File

@ -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(),

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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),

View File

@ -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;